Cron on macOS: launchd Jobs and Why Yours Didn't Run
Contents
Cron still works on macOS, but Apple deprecated it in favor of launchd: to schedule a job on a Mac, write a property list with a StartCalendarInterval key, save it in ~/Library/LaunchAgents, and load it with launchctl bootstrap gui/$(id -u). The biggest practical difference is sleep. launchd runs a calendar job that came due while the Mac was asleep as soon as it wakes; cron skips it. When a Mac job doesn't run, the cause is usually a plist launchd never loaded, a PATH without Homebrew, or macOS privacy protections answering "Operation not permitted".
Checked October 25, 2026 against Apple's Daemons and Services Programming Guide and the man pages installed with macOS 26.6. Apple doesn't publish current man pages online, so those links go to a public copy of the pages Xcode ships.
Cron Still Works on macOS, but Apple Deprecated It#
Apple's guide to timed jobs says: "Although it is still supported, cron is not a recommended solution. It has been deprecated in favor of launchd" (Scheduling Timed Jobs). That guide sits in Apple's documentation archive, but macOS 26.6 still ships /usr/sbin/cron, a Vixie cron, and its crontab(5) says cron's "functionality has been absorbed into launchd(8)." crontab -e works as it does on Linux, and launchd starts cron when it sees /etc/crontab or a file in /usr/lib/cron/tabs (cron(8)).
| Behavior | cron | launchd |
|---|---|---|
| Where a job is defined | One crontab line | One property list file per job |
| How the command runs | Through /bin/sh |
Executed directly, with no shell |
| Environment | SHELL, HOME, LOGNAME, plus crontab variable lines |
EnvironmentVariables in the plist |
| Output | Mailed to the crontab's owner | Files named in StandardOutPath and StandardErrorPath |
| Last run's exit status | Not recorded | Shown by launchctl print |
Use cron when one crontab line must behave the same on a Mac and a Linux server. Otherwise, and always on a laptop, use launchd.
LaunchAgent or LaunchDaemon#
Where you put the plist decides who runs the job and when:
| Folder | Runs as | Runs | Plist owned by |
|---|---|---|---|
~/Library/LaunchAgents |
You | While you're logged in | You |
/Library/LaunchAgents |
Each user who logs in | While that user is logged in | root |
/Library/LaunchDaemons |
root, or the account named in UserName |
From boot, with no login | root |
Apple describes an agent as running "only while that user is logged in": a per-user launchd loads agents at login, and launchd loads daemons at boot (Creating Launch Daemons and Agents). The ownership rules are in launchctl(1), and UserName works only for jobs in the system domain (launchd.plist(5)).
Use a LaunchAgent for your own scripts: no sudo, and it runs as you. On an unattended Mac mini, agents stop after a reboot until someone logs in, and the gui/<uid> domain the commands below target exists only once a user "logs in at the GUI" (launchctl(1)). A backup or build job that must run without a login belongs in /Library/LaunchDaemons, with UserName set to a dedicated account rather than root.
A Weekday 09:00 LaunchAgent#
This agent runs a script at 09:00, Monday through Friday. Save it as ~/Library/LaunchAgents/com.example.weekday-report.plist, replacing sam with your short user name. It runs the monitoring wrapper shown at the end; without monitoring, point ProgramArguments at the job's own script:
<?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.weekday-report</string>
<key>ProgramArguments</key>
<array>
<string>/Users/sam/bin/weekday-report-monitored.sh</string>
</array>
<key>StartCalendarInterval</key>
<array>
<dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>2</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>3</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>4</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>5</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>WorkingDirectory</key>
<string>/Users/sam/reports</string>
<key>StandardOutPath</key>
<string>/Users/sam/Library/Logs/weekday-report.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/sam/Library/Logs/weekday-report.err.log</string>
</dict>
</plist>What each key does, per launchd.plist(5):
Labelis required and unique.launchctlcommands take the label, not the file name, so name the file<Label>.plist.ProgramArgumentsgoes toexecvp(3)with no shell, so~,$HOME,&&, pipes, and redirection don't work. Use absolute paths and put the logic in a script. A bare command name is searched for only in/usr/bin:/bin:/usr/sbin:/sbin, the_PATH_STDPATHof paths.h.StartCalendarIntervaltakes a dictionary or an array of them, withMinute,Hour,Day,Weekday, andMonth. "Missing arguments are considered to be wildcard," soHour9 withoutMinutefires every minute from 09:00 to 09:59. Values are single integers, not ranges or lists, so Monday to Friday takes five dictionaries.Weekday0 and 7 are both Sunday.EnvironmentVariablestakes string values. ThisPATHadds Homebrew's/opt/homebrew/bin(Apple silicon) and/usr/local/bin.WorkingDirectoryis where relative paths in the script resolve. Without it, they resolve against wherever launchd starts the job, not the folder you tested in.StandardOutPathandStandardErrorPathare created if missing. Without them, launchd sends the job's output to/dev/null.
RunAtLoad is left out because the man page says to avoid it; test with launchctl kickstart instead.
Don't restrict both Day and Weekday. The man page says the job then starts "if either one matches," the archived guide's example says both must match, and the last open-source launchd took the earlier of the two times (core.c).
Which Clock It Follows#
launchd.plist has no time zone key, and calendar intervals follow the Mac's local time. The last open-source launchd, 842.92.1 from OS X 10.9.5, computes the next run with localtime and mktime (core.c); Apple hasn't published launchd's source since. Neither the man page nor the guide says what a calendar interval does in the hour that daylight saving time skips or repeats, so keep local-time jobs out of 01:00 to 03:00 (cron daylight saving time shows what other schedulers do then).
Load, Run, and Inspect It With launchctl#
Check the plist, create the folders it names, make the script executable, and load the agent:
plutil -lint ~/Library/LaunchAgents/com.example.weekday-report.plist
mkdir -p ~/Library/Logs ~/reports
chmod 755 ~/bin/weekday-report-monitored.sh
chmod 644 ~/Library/LaunchAgents/com.example.weekday-report.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.weekday-report.plist
launchctl kickstart -k gui/$(id -u)/com.example.weekday-report # run it once now
launchctl print gui/$(id -u)/com.example.weekday-report # state and last exit statusplutil -lint checks the file's syntax (plutil). kickstart runs a job "immediately, regardless of its configured launch conditions" (-k kills a running instance first), and print shows where the job was loaded from, its state, and its last exit status (launchctl(1)). Apple says that output "is NOT API," so read it; don't parse it.
To apply an edit, boot the job out and bootstrap it again:
launchctl bootout gui/$(id -u)/com.example.weekday-report
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.weekday-report.plistDecode a numeric bootstrap error with launchctl error, for example launchctl error 5. A disabled service "cannot be loaded" until it's enabled again, even after a reboot: launchctl print-disabled gui/$(id -u) lists disabled services, and launchctl enable gui/$(id -u)/com.example.weekday-report clears the flag.
A LaunchDaemon's plist is owned by root and loads into the system domain:
sudo chown root:wheel /Library/LaunchDaemons/com.example.nightly-backup.plist
sudo chmod 644 /Library/LaunchDaemons/com.example.nightly-backup.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.nightly-backup.plistOlder guides use launchctl load and unload. The man page lists them as legacy subcommands, recommends bootstrap, bootout, enable, and disable instead, and notes that load and unload "will only return a non-zero exit code due to improper usage," so a script can't tell whether loading worked.
What Sleep and Shutdown Do to a Schedule#
Apple's guide gives the rules (Scheduling Timed Jobs):
- cron: if the Mac is asleep or off, the job doesn't run until the next scheduled time.
StartCalendarInterval: if the Mac is asleep, "your job will run when the computer wakes up." If it's off, the job waits for the next scheduled time.- Every other launchd job,
StartIntervalincluded, is skipped while the Mac is asleep or off.
If several calendar intervals pass during one sleep, "those events will be coalesced into one event upon wake" (launchd.plist(5)): a laptop closed from Friday evening to Monday at 10:00 runs a daily job once, at 10:00. Older copies of the man page, such as the 2009 one in Apple's open-source launchd, say StartInterval catches up on wake too (launchd.plist.5); the current page says it doesn't.
A Mac mini that must run a 09:00 job can stay awake, restart after a power failure, and power on before the job (pmset(1)):
sudo pmset -a sleep 0 autorestart 1
sudo pmset repeat wakeorpoweron MTWRF 08:55:00
pmset -g schedpmset keeps one repeating power-on event and one power-off event. After an automatic restart, agents still wait for someone to log in, so this matters most for daemons. Missed runs after downtime compares how other schedulers catch up.
Why Your Mac Job Didn't Run#
Start with launchctl print. If it can't find the service, the job isn't loaded; a non-zero last exit status means it ran and failed.
It Isn't Loaded, or Not Under That Name#
At login, launchd loads agents only from /System/Library/LaunchAgents, /Library/LaunchAgents, and ~/Library/LaunchAgents, so a plist bootstrapped from a project folder works until you log out and never loads again. And commands take gui/<uid>/<Label>: if the file is weekday-report.plist but its label is com.example.weekday-report, commands built from the file name find nothing.
It Can't Find a Command or a File#
There's no shell, so nothing reads ~/.zprofile or ~/.zshrc, where Homebrew's installer has you add brew shellenv (Homebrew). Homebrew's FAQ notes that GUI apps "don't have Homebrew's prefix in their PATH by default" (FAQ), and a script that calls Homebrew's node or ffmpeg logs "command not found." Set PATH in EnvironmentVariables, or use absolute paths in the script. For the same problem under cron, see cron environment variables.
The Plist Has the Wrong Owner or Permissions#
launchctl requires that a plist "disallow group and world writes," that agents in your home folder be owned by you, and that plists in /Library be owned by root. A plist created with sudo in ~/Library/LaunchAgents is owned by root and breaks that rule; fix it with chown and chmod 644.
Operation Not Permitted: macOS Privacy Protections#
macOS protects the Desktop, Documents, and Downloads folders, network volumes, and removable volumes, each as its own privacy permission (Apple Platform Deployment). A blocked operation fails with EPERM, "Operation not permitted," where a Unix permission problem gives EACCES, "Permission denied" (Apple DTS, On File System Permissions). launchd.plist(5) warns that jobs "are subject to macOS user privacy protections" and that naming privacy-sensitive folders in a plist "may prevent the job from running," so a script or log file in ~/Documents can stop the job before it starts.
The command works in Terminal because commands run there inherit Terminal's access, and TCC, the system behind these permissions, sees a shell script only as its interpreter. As an Apple DTS engineer puts it, "when you run a shell script the process is running sh ... and that's all TCC knows about it" (forum thread). A cron or launchd job has no Terminal above it.
Fixes, best first:
- Keep the job out of protected folders. The script, working directory, data, and logs can live in
~/bin,~/reports, and~/Library/Logs. - For a job that must read protected data, such as a backup of
~/Documents, Apple DTS recommends packaging the work as a standalone program the system can track, rather than giving an interpreter access that "any script" would then have (On File System Permissions). - Full Disk Access for
/usr/sbin/cronor a shell is a widely shared workaround that Apple doesn't document. In System Settings > Privacy & Security > Full Disk Access, click the add button and press Shift-Command-G to type the path. Apple DTS calls the way TCC traces a process to its responsible code "both undocumented and complex," so nothing guarantees it keeps working, and when it works, every cron job, or every script that shell runs, can read all your data. On October 2, 2026, Apple announced additional controls so that users who want to grant Full Disk Access "can only do so with very explicit user action" (Apple Developer News), without naming a release.
It's Switched Off in Login Items#
Since macOS 13, launch agents and daemons have a switch in System Settings > General > Login Items, called Login Items & Extensions from macOS 15 (Mac User Guide). Apple says the switch allows them to run "when they log in, or in the case of a LaunchDaemon, when the system starts up." When macOS can't attribute a job to an app and its program isn't signed, it shows "the executable name from the Program or ProgramArguments key" (Apple), so give the wrapper a name you'll recognize, and check that its switch is on.
It Ran and Failed#
Read the StandardErrorPath file. Without KeepAlive, launchd doesn't restart a job that exits with an error, so the next attempt is the next scheduled time. For a crontab entry, cron mails the job's output to the crontab's owner on the Mac. Cron job not running covers the remaining crontab problems.
Monitor the Job With a CronRadar Check-In#
None of these checks tells you that the 09:00 run didn't happen while you were away; an external check-in does. There's no CronRadar SDK for launchd or cron and no auto-discovery, so each job reports with its own HTTP check-in from the script launchd runs.
Keep the API key out of the plist. launchd requires only that others can't write a plist, not that they can't read it, and plists end up in dotfiles repositories and backups. Copy the key from the CronRadar dashboard and save it where only your account can read it:
mkdir -p ~/.config/cronradar
(umask 077 && pbpaste > ~/.config/cronradar/api-key)Convert 09:00 Local to UTC#
CronRadar evaluates every schedule in UTC (schedule syntax), while launchd follows the Mac's zone, which sudo systemsetup -gettimezone prints. macOS date parses a local time with -j -f and prints UTC with -u -r (date). Compare a summer and a winter date:
date -u -r "$(TZ=America/New_York date -j -f '%Y-%m-%d %H:%M' '2026-07-15 09:00' +%s)" +%H:%M # 13:00
date -u -r "$(TZ=America/New_York date -j -f '%Y-%m-%d %H:%M' '2026-01-15 09:00' +%s)" +%H:%M # 14:00The times differ, so the zone has daylight saving time and no single UTC expression follows it. CronRadar's rule is to send the earlier UTC time, with a grace period of at least an hour plus the job's usual duration. For a run of up to ten minutes, that's 0 13 * * 1-5 with gracePeriod=4200. If the conversion crosses midnight, shift the day fields too: weekdays at 09:00 in Sydney are 22:00 or 23:00 UTC the day before, so 0 22 * * 0-4. Cron timezone explains the trade-offs.
The Wrapper Script#
Save this as /Users/sam/bin/weekday-report-monitored.sh. It reports start, runs the job, reports complete or fail, and exits with the job's own code. Each report gives up after five seconds and ignores errors, so monitoring can't fail the job:
#!/bin/sh
# /Users/sam/bin/weekday-report-monitored.sh
# launchd runs this at 09:00 New York time on weekdays: 13:00 UTC in summer,
# 14:00 UTC in winter. Send the earlier time, with 1 hour plus a 10-minute run as grace.
CRONRADAR_API_KEY=$(cat /Users/sam/.config/cronradar/api-key 2>/dev/null)
url="https://cron.life/api/v1/ping/weekday-report"
run_id="weekday-report-$(date +%s)-$$"
report() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=0 13 * * 1-5" \
--data-urlencode "gracePeriod=4200" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
report start
/Users/sam/bin/weekday-report.sh
rc=$?
if [ "$rc" -eq 0 ]; then
report complete
else
report fail --data-urlencode "message=weekday-report.sh exited with code $rc"
fi
exit "$rc"Each check-in carries the schedule, so the kickstart run after loading creates the monitor, and a deleted monitor comes back on the next run. Don't skip that first run: a monitor created by its first check-in can't flag a job that never runs. schedule and gracePeriod apply only when a check-in creates the monitor, so change them later in the monitor's settings. A fail report alerts immediately, a missed run goes critical once the grace period passes, and the start and complete pair records each run's duration.
On a laptop, expect this: asleep at 09:00 and awake at 10:30, the job runs on wake, but CronRadar has already opened an incident for the missed run, which the late run's complete resolves with a recovery message. The alert is accurate, because the job didn't run on time. If any time that morning is fine, widen the grace period in the monitor's settings. In winter, the monitor also shows warning for the hour before each 14:00 UTC run.
Frequently Asked Questions#
Does cron work on macOS?#
Yes. macOS 26.6 still includes cron, and crontab -e works as on Linux. Apple calls it "still supported" but deprecated in favor of launchd. Cron skips any run that comes due while the Mac is asleep, and because cron is itself a launchd daemon, its jobs face the same privacy protections.
What is the launchd equivalent of a cron job?#
A property list with a StartCalendarInterval key in ~/Library/LaunchAgents, loaded with launchctl bootstrap gui/$(id -u). Each dictionary takes Minute, Hour, Day, Weekday, and Month, and a missing key means every value. Lists and ranges need an array of dictionaries, one per time.
Does launchd run missed jobs after sleep?#
Only StartCalendarInterval jobs. If the Mac was asleep at the scheduled time, launchd runs the job once on wake, however many runs were missed. StartInterval jobs and cron jobs skip runs that came due during sleep, and neither runs a job missed while the Mac was powered off.
Why does my Mac cron job say Operation not permitted?#
macOS privacy protections are blocking it. The Desktop, Documents, and Downloads folders, removable volumes such as external drives, and network volumes need a permission that Terminal may have but a background job doesn't. Move the job's files out of those locations. Granting /usr/sbin/cron Full Disk Access is an undocumented workaround that gives every cron job access to all your data.
How do I see whether a launchd job ran?#
Run launchctl print gui/$(id -u)/com.example.weekday-report with your job's label and read its state and last exit status, then check the StandardOutPath and StandardErrorPath files. To be alerted when an expected run is missing or fails, add an external check-in such as the CronRadar wrapper above.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.