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+.
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
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:
-
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 ~/Documentswill fail. To grant: System Settings → Privacy & Security → Full Disk Access → add/usr/sbin/cron. The path is hidden, so you have to navigate manually. -
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.
-
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.
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
| Cron | Launchd |
|---|---|
*/15 * * * * | <key>StartInterval</key><integer>900</integer> |
0 9 * * * | Hour 9, Minute 0 |
0 9 * * 1-5 | Use 5 calendar entries with Weekday 1–5 |
@reboot | <key>RunAtLoad</key><true/> |
@daily | Hour 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
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, notbrew. - 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.
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.