Codonic

LD Manager guide · launchd

How to replace cron with launchd on a Mac

cron skips runs while the Mac sleeps and trips over privacy protections. How each crontab field maps to StartCalendarInterval, a complete plist, and loading, testing and removing the old line.

Why cron jobs fail on a Mac

Apple’s own documentation is plain about it: “Although it is still supported, cron is not a recommended solution. It has been deprecated in favor of launchd.” It still ships (/usr/sbin/cron, started by launchd when a crontab exists), but four things make it unreliable on a Mac:

  • Sleep. If the Mac is asleep at the scheduled time, cron skips that run. Laptops sleep most nights, so a 02:30 backup may never happen.
  • Privacy protections. A cron job that reads or writes Desktop, Documents, Downloads, iCloud Drive or an external disk fails with Operation not permitted, even though the same command works in Terminal. The workaround people give each other in Apple’s forums is to add /usr/sbin/cron to Full Disk Access in System Settings › Privacy & Security, which gives every cron job that access.
  • A bare environment. cron runs the command with /bin/sh, sets little more than SHELL, HOME and LOGNAME, and reads none of your shell’s settings, so a command found in Terminal is not found in cron.
  • Quiet failures. Output goes to local mail, which almost nobody reads on a Mac, so a failing job looks like a job that never ran.

launchd fixes the first one and makes the others visible: it runs a missed calendar job when the Mac wakes, and you choose where its output goes.

Translate the schedule

StartCalendarInterval takes the same five fields as cron, as integer keys. A field you leave out is a wildcard, like *.

cron field launchd key Range
minute Minute 0–59
hour Hour 0–23
day of month Day 1–31
month Month 1–12
day of week Weekday 0–7, where 0 and 7 are Sunday

There are no ranges, lists or steps. Where cron has a list or a range, launchd takes an array of dictionaries, one per combination:

crontab launchd
30 2 * * * {Hour 2, Minute 30}
0 * * * * {Minute 0}
30 9 * * 1 {Weekday 1, Hour 9, Minute 30}
0 9 * * 1-5 an array of five: {Weekday 1, Hour 9, Minute 0} … {Weekday 5, Hour 9, Minute 0}
0 0 1 * * {Day 1, Hour 0, Minute 0}
*/15 * * * * an array of four: {Minute 0}, {Minute 15}, {Minute 30}, {Minute 45}; or StartInterval 900
@reboot RunAtLoad true: at login for an agent, at boot for a daemon

Two differences to know. If you set both Day and Weekday, launchd starts the job when either one matches, as cron does. And StartInterval (every N seconds) is not a cron replacement for jobs that must not be missed: man launchd.plist says an interval that falls while the Mac is asleep is skipped. Use StartCalendarInterval for those.

Write the plist

This replaces 30 2 * * * /Users/alice/bin/backup.sh >> /Users/alice/backup.log 2>&1:

<?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.example.backup</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/zsh</string>
    <string>/Users/alice/bin/backup.sh</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key>
    <integer>2</integer>
    <key>Minute</key>
    <integer>30</integer>
  </dict>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
  <key>StandardOutPath</key>
  <string>/Users/alice/Library/Logs/backup.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/alice/Library/Logs/backup.log</string>
</dict>
</plist>

Save it as ~/Library/LaunchAgents/com.example.backup.plist. The rules that trip people up:

  • The Label is a unique reverse-DNS name, and by convention the file is named after it.
  • Write paths out in full. launchd does not expand ~ or $HOME, in the program or in the log paths.
  • One argument per string. ProgramArguments is not a shell line: no pipes, no >>, no &&. Put those in the script, or run /bin/sh with -c and the whole line as one string.
  • launchd sets its own minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin); add Homebrew’s folder with EnvironmentVariables if the script needs it.
  • The log folder must exist. launchd creates the log file, not its folder. A missing folder makes the job fail with exit code 78.

Load it, test it, remove the cron line

plutil -lint ~/Library/LaunchAgents/com.example.backup.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.backup.plist
launchctl kickstart gui/$(id -u)/com.example.backup        # run it once, now
launchctl print gui/$(id -u)/com.example.backup | grep -E 'state|last exit'

last exit code = 0 after the test run means it worked. An agent in ~/Library/LaunchAgents also loads by itself at every login, so you only bootstrap it this once. If you change the file later, launchctl bootout gui/$(id -u)/com.example.backup and bootstrap it again; launchd keeps the copy it loaded.

Then take the line out of your crontab, so the job doesn’t run twice:

crontab -l          # see what's there
crontab -e          # delete the line

Agent or daemon?

A LaunchAgent in ~/Library/LaunchAgents runs as you, only while you are logged in. That is right for most personal jobs, and it can reach your files.

A job that must run with nobody logged in, such as on a Mac mini used as a server, is a LaunchDaemon: put the plist in /Library/LaunchDaemons, owned by root:wheel with mode 644, add UserName if it should not run as root, and load it into the system domain:

sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.backup.plist

Either way, a Mac that is switched off at the scheduled time does not run the job until the next scheduled time. Apple’s documentation spells that out too: launchd catches up after sleep, not after shutdown.

Privacy access still applies

Moving a job from cron to launchd does not lift macOS’s privacy protections. If the job touches Desktop, Documents, Downloads or a removable disk, either keep its files elsewhere, or give the program that does the work (the script’s interpreter or the binary) Full Disk Access in System Settings › Privacy & Security, and test with launchctl kickstart.

The shortcut: LD Manager

LD Manager is a free, open-source Mac app and command-line tool for launchd jobs. ldm new writes the plist, with full paths, and loads it:

ldm new backup --cron "30 2 * * *" --log ~/Library/Logs/backup.log -- ~/bin/backup.sh
ldm new report --cron "30 9 * * 1" -- /Users/alice/bin/report.sh
ldm new sync --every 15m -- /Users/alice/bin/sync.sh
ldm new backup --cron "30 2 * * *" --dry-run -- ~/bin/backup.sh   # print the plist instead

--cron turns numbers, * and comma lists into StartCalendarInterval entries; ranges and steps such as 1-5 or */15 are refused, so list the values or use --every. ldm expands ~ before writing, and finds the program’s full path. A bare name such as backup becomes the Label local.<user>.backup.

The app shows every job’s schedule in words (“Mondays at 09:30”) with the next runs, the last exit code explained, and the job’s log one click away. Edits show a diff before saving and keep a backup. LD Manager needs macOS 26 or later; it does not read or convert your crontab, so moving each line over is still up to you.

cron and launchd

Is cron deprecated on macOS?

Apple’s documentation says cron is still supported but not recommended, and has been deprecated in favour of launchd. It still ships and runs any crontab you install, but launchd handles sleep, logging and privacy access better.

Why does my cron job say “Operation not permitted”?

macOS privacy protections stop cron jobs from reading Desktop, Documents, Downloads, iCloud Drive and external disks. People work around it by giving /usr/sbin/cron Full Disk Access; moving the job to launchd and keeping its files outside protected folders is cleaner.

Does launchd run a job the Mac slept through?

A StartCalendarInterval job, yes: it runs when the Mac wakes, and several missed runs become one. A StartInterval job does not catch up, and no launchd job runs for a time when the Mac was switched off.

How do I write */15 or 1-5 in StartCalendarInterval?

There are no steps or ranges. Use an array of dictionaries, one per value: four entries with Minute 0, 15, 30 and 45, or five entries with Weekday 1 to 5. For “every 15 minutes” you can also use StartInterval 900, which skips intervals during sleep.

Where does a LaunchAgent plist go?

In ~/Library/LaunchAgents for a job that runs as you while you are logged in. A job that must run with nobody logged in is a LaunchDaemon in /Library/LaunchDaemons, owned by root:wheel with mode 644, loaded with sudo launchctl bootstrap system.