Sweepfor Mac

Mac maintenance

How to Set Up Cron Jobs on Mac (and the launchd Alternative)

Set up cron jobs on macOS — when cron still works, what to use instead, and how to migrate to launchd for reliable scheduled tasks on Sonoma+.

9 min read

You want a script to run every Tuesday at 3 AM. On Linux, you’d reach for crontab. On macOS, cron is technically still here — Apple ships it with the OS — but it’s been deprecated since Tiger, and Apple has been increasingly aggressive about pushing you to use launchd instead. As of recent macOS versions, cron jobs that read sensitive locations need Full Disk Access, which is a hassle to grant.

So the modern Mac answer to “schedule something to run” is split: cron still works for simple cases, launchd is the official replacement, and there’s a third option (pmset repeat) for system-level wake/sleep schedules. Here’s the practical guide.

Cron: the quick version

Cron reads a per-user crontab file (/var/at/tabs/<user>) and runs jobs at the times you specify. To edit yours:

crontab -e

That opens your crontab in your default editor (Vim, usually). Add lines in this format:

# minute hour day-of-month month day-of-week command
0 3 * * 2 /Users/you/bin/cleanup.sh

That runs cleanup.sh every Tuesday at 3 AM. Save and exit; cron picks up the change immediately.

To list your current cron jobs:

crontab -l

To remove all of them:

crontab -r

The cron field reference

*    *    *    *    *    command
│    │    │    │    │
│    │    │    │    └── day of week (0-7, 0 and 7 are Sunday)
│    │    │    └─────── month (1-12)
│    │    └──────────── day of month (1-31)
│    └───────────────── hour (0-23)
└────────────────────── minute (0-59)

Each field accepts:

  • A specific number: 30
  • A range: 9-17
  • A list: 1,15
  • A step: */5 (every 5 minutes), 0-30/5 (every 5 minutes from 0 to 30)

Some examples:

*/15 * * * *           # every 15 minutes
0 9 * * 1-5            # 9 AM, Monday through Friday
0 0 1 * *              # midnight on the 1st of every month
30 4 * * 0             # 4:30 AM every Sunday
@reboot                # at startup (cron's "specials")
@daily                 # equivalent to 0 0 * * *
@weekly                # equivalent to 0 0 * * 0
Tip: The day-of-month and day-of-week fields are OR'd, not AND'd. 0 0 1 * 1 runs at midnight on the 1st or on Monday — not "the 1st when it's a Monday."

Where cron breaks on modern macOS

Three things go wrong:

  1. Full Disk Access requirement. As of recent macOS versions, cron itself needs FDA to read most user files. Without it, your job runs but cd ~/Documents will fail. To grant: System Settings → Privacy & Security → Full Disk Access → add /usr/sbin/cron. The path is hidden, so you have to navigate manually.

  2. GUI permissions. Jobs that need to interact with the user’s GUI session (open an app, run AppleScript that touches Finder) often fail in cron because cron jobs run outside the GUI session.

  3. Apple keeps quietly tightening. Each macOS release seems to add new restrictions. Your cron job that worked under Big Sur might fail under Sonoma without warning.

For these reasons, Apple recommends launchd for any new scheduling work.

Launchd: the modern replacement

A LaunchAgent runs in your user’s GUI session. A LaunchDaemon runs as root, system-wide. For most personal scheduled tasks, you want an agent.

A minimal “run this script every 30 minutes” agent:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.you.cleanup</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/you/bin/cleanup.sh</string>
    </array>
    <key>StartInterval</key>
    <integer>1800</integer>
    <key>StandardOutPath</key>
    <string>/tmp/cleanup.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/cleanup.err</string>
</dict>
</plist>

Save it as ~/Library/LaunchAgents/com.you.cleanup.plist, then:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.cleanup.plist

That registers the agent and starts the timer. The script will run every 30 minutes when you’re logged in.

Power users use Sweep tooEven when you know the Terminal commands, Sweep is faster and harder to mess up. Get Sweep free →

Calendar-style scheduling in launchd

For “every Tuesday at 3 AM,” use StartCalendarInterval instead of StartInterval:

<key>StartCalendarInterval</key>
<dict>
    <key>Hour</key>
    <integer>3</integer>
    <key>Minute</key>
    <integer>0</integer>
    <key>Weekday</key>
    <integer>2</integer>
</dict>

Weekday is 0 (Sunday) through 6 (Saturday). Apple’s docs say 0 or 7 for Sunday — both work.

For multiple times, use an array of dicts:

<key>StartCalendarInterval</key>
<array>
    <dict>
        <key>Hour</key><integer>3</integer>
        <key>Minute</key><integer>0</integer>
        <key>Weekday</key><integer>2</integer>
    </dict>
    <dict>
        <key>Hour</key><integer>15</integer>
        <key>Minute</key><integer>0</integer>
        <key>Weekday</key><integer>5</integer>
    </dict>
</array>

That runs at 3 AM Tuesday and 3 PM Friday.

Cron-to-launchd cheat sheet

CronLaunchd
*/15 * * * *<key>StartInterval</key><integer>900</integer>
0 9 * * *Hour 9, Minute 0
0 9 * * 1-5Use 5 calendar entries with Weekday 1–5
@reboot<key>RunAtLoad</key><true/>
@dailyHour 0, Minute 0

Cron is more compact for “every weekday at 9.” Launchd is more verbose for that case but more powerful for everything else (paths watched, KeepAlive behavior, environment variables).

Watching a folder, not a clock

Launchd’s killer feature over cron: triggering on file changes.

<key>WatchPaths</key>
<array>
    <string>/Users/you/Downloads</string>
</array>

Add that to your plist and the job fires whenever anything in ~/Downloads changes. Use cases: auto-organize downloads, kick off a backup whenever a specific folder gets a new file, run a linter when source code changes.

There’s no cron equivalent.

Running while sleeping

By default, neither cron nor launchd wakes a sleeping Mac. If your Mac is asleep at the scheduled time, the job runs when it next wakes — but only the most recent run, not every missed one.

To make a job wake the Mac, you need to combine launchd with pmset:

sudo pmset repeat wakeorpoweron MTWRFSU 02:55:00

That schedules a wake every day at 2:55 AM. Then your launchd job at 3 AM has a Mac that’s awake.

Note: this won’t wake a Mac that’s off. It works during sleep only.

A practical example: nightly cleanup

The script (~/bin/nightly-cleanup.sh):

#!/bin/bash
# Runs nightly at 3 AM
{
  echo "=== $(date) ==="

  # Brew
  brew cleanup -s
  brew autoremove

  # Docker
  docker system prune -f 2>/dev/null

  # Trash on the system disk
  rm -rf ~/.Trash/*

  # Old downloads
  find ~/Downloads -mtime +90 -type f -delete
} >> /tmp/nightly-cleanup.log 2>&1

Make executable:

chmod +x ~/bin/nightly-cleanup.sh

The plist (~/Library/LaunchAgents/com.you.nightly-cleanup.plist):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.you.nightly-cleanup</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/you/bin/nightly-cleanup.sh</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key><integer>3</integer>
        <key>Minute</key><integer>0</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/tmp/nightly-cleanup.out</string>
    <key>StandardErrorPath</key>
    <string>/tmp/nightly-cleanup.err</string>
</dict>
</plist>

Load it:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.nightly-cleanup.plist

To run it manually for testing:

launchctl kickstart gui/$(id -u)/com.you.nightly-cleanup

To unload:

launchctl bootout gui/$(id -u)/com.you.nightly-cleanup

Skip the Terminal stackSweep does the same cleanup with a UI and a preview before anything is removed. Download Sweep free →

Environment variables

Cron and launchd both run with a minimal environment. Your usual PATH, locale, and shell aliases aren’t there. Make scripts robust:

  • Use absolute paths: /usr/local/bin/brew, not brew.
  • Set explicit environment in the script: export PATH="/usr/local/bin:/usr/bin:/bin" at the top.
  • For locale-dependent commands: export LANG=en_US.UTF-8.

In launchd you can also set environment variables in the plist:

<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>

Debugging when a job doesn’t fire

Cron:

log show --predicate 'process == "cron"' --info --last 1h

Launchd:

log show --predicate 'subsystem == "com.apple.xpc.launchd"' --info --last 1h | grep com.you.nightly-cleanup

For launchd, also check the job state:

launchctl print gui/$(id -u)/com.you.nightly-cleanup

That shows last run time, last exit status, and (if it’s failed to launch) the reason.

Common errors

“Permission denied” in the log. The script doesn’t have execute permission, or it’s accessing files the runtime context can’t read. Fix the chmod, or grant Full Disk Access to the binary that’s running the job.

Job runs but does nothing. The script ran in cron’s stripped environment and command not found on something. Check stderr: cat /tmp/yourjob.err.

Launchd job is “loaded” but never runs. RunAtLoad is missing for a job with no schedule, or the calendar interval is malformed.

Job runs constantly. You set both StartInterval and KeepAlive, and the job exits immediately. Pick one or the other.

When neither cron nor launchd is right

Some scheduled tasks are better handled by:

  • Time Machine — for backups. Runs hourly, transparently.
  • Sweep / a cleanup app — for “find stuff to clean up regularly.” A scanner that runs on demand and previews before deleting beats a cron job that silently rms files in the background.
  • App-specific schedulers — if Photos, Music, or Mail has its own background sync, use that, not a cron wrapper.
  • Hammerspoon or Keyboard Maestro — for “do this when something happens in the GUI.” More expressive than launchd’s WatchPaths.

There’s a GUI for thatSweep wraps the Terminal cleanup in a UI you don’t have to memorize. Try Sweep free →

Bottom line

If you’re scheduling a one-off “run this script every X” and want minimum hassle, cron still works — just be ready to grant Full Disk Access. If you want it to keep working through future macOS upgrades, write a launchd plist. The plist is more verbose but more honest about what’s happening, and it’s the path Apple is committed to.

For Mac maintenance specifically, consider whether you really need automation. A scheduled cleanup that runs silently can quietly delete files you actually wanted, and the time you “save” gets eaten by the one time it removes something important. A predictable, manual run that you trigger when you choose is often the better tradeoff.

← Back to all guides