crontab -e still works on macOS as of Sequoia. The cron daemon runs, jobs execute on schedule, and there is no error. The catch is that the OS no longer guarantees the daemon starts on boot, no longer runs missed jobs after a sleep, and Apple has been recommending launchd as the replacement for over a decade. That is why a crontab that has worked for a year can stop working after a macOS update, a permissions change, or a power event. The job is still there. The system is no longer running it.
The reason “mac crontab” is still a heavily searched query is that crontab is the path of least resistance for engineers who came from Linux. It is also the path of least surprise for jobs that have to be portable between a Mac laptop and a Linux server. The trade-off is that the OS treats crontab as legacy, and legacy on macOS means “works until it does not.”
This post is the version I would want to read before assuming the cron job I just added is going to run tomorrow.
Table of contents
- The direct answer: crontab still works, with caveats
- The five reasons a mac crontab stops running
- The launchd alternative, in the same shape as cron
- When to use which
- A working launchd plist you can copy
- The macOS sleep problem and the right fix
- FAQ
The direct answer: crontab still works, with caveats
A working crontab on a Mac looks like this:
# Edit the current user's crontab
crontab -e
# List it
crontab -l
# Remove it
crontab -r
The format is the same as Linux: m h dom mon dow command. The default editor is whatever $EDITOR says, or vi if $EDITOR is not set. The daemon is /usr/sbin/cron, and you can confirm it is running with sudo launchctl list | grep cron.
The caveats that matter in production:
- The cron daemon is not guaranteed to start on boot. Apple has been moving scheduled work to launchd for over a decade. The daemon usually starts, but on a fresh install of a recent macOS, the only guarantee is “it works when it works.”
- Missed jobs during sleep are not caught up. cron does not know the laptop was asleep. The job scheduled for 2 a.m. while the lid was closed simply does not run. Linux’s anacron solves this; macOS cron does not.
- Full Disk Access breaks cron in non-obvious ways. If the script touches
~/Library,~/Documents, or anything TCC-protected, and the parent process is cron (which is not a foreground app), the system denies access silently. The cron job runs, the inner command fails, and the only sign of trouble is a non-zero exit code in the mail log. - macOS updates have reset cron in the past. Not often, but enough that any job critical to a workflow needs a verification step after a major OS upgrade.
- Apple added a “Legacy” toggle in System Settings for Sequoia that, when off, disables cron entirely for that user. The default is on, but a user who has been poking around System Settings can quietly turn it off.
The job is the same job it has always been. The runtime is the part that is no longer the system’s first choice.
The five reasons a mac crontab stops running
The most common support thread on r/MacOS and Stack Exchange for “my crontab stopped working” follows one of five patterns.
The Full Disk Access prompt is unhandled. The script reads ~/Library/Mail or ~/Library/Calendars and TCC denies the access because cron is not a foreground app. Fix: grant Full Disk Access to the terminal that runs crontab -e (cron inherits the parent process’s permissions in some configurations) or move the file the script reads out of a TCC-protected directory.
The path is not what the script expects. Cron runs with a stripped PATH (usually just /usr/bin:/bin). A script that calls node or python from a Homebrew install at /opt/homebrew/bin/ will fail with “command not found.” Fix: set PATH= at the top of the crontab or call the binary with an absolute path.
The script depends on a GUI session. Cron runs in a non-GUI context. A script that opens a window, talks to the WindowServer, or writes to a TCC-protected location will fail or hang. Fix: if the script needs the GUI, it is not a cron job — it is a launchd agent, not a launchd daemon.
The laptop was asleep at the scheduled time. Cron does not replay missed jobs. The job simply did not run. Fix: use launchd with StartCalendarInterval plus a KeepAlive condition, or accept that the job runs on the next cron check after wake.
macOS update changed the cron binary path or removed it. Rare, but real. Fix: verify with which cron and /usr/sbin/cron --version, and check the release notes for the macOS version you just installed.
The first three cover about 90% of the “my cron job stopped working” reports. The audit is a one-line crontab -l, a ls of the script, and a manual run of the script with the same environment cron uses (env -i /bin/bash).
The launchd alternative, in the same shape as cron
launchd is two things at once: a process supervisor (replacing init) and a job scheduler (replacing cron). The scheduler reads .plist files from three locations:
/System/Library/LaunchDaemons/— system-level daemons (read-only, owned by Apple)./Library/LaunchDaemons/— third-party system-level daemons (run as root, started at boot).~/Library/LaunchAgents/— per-user agents (run when the user logs in).
For a user-level scheduled job (the cron replacement), the file goes in ~/Library/LaunchAgents/ and is owned by the user.
A crontab entry like:
0 9 * * * /Users/me/bin/backup.sh
Becomes a launchd plist like:
<?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.me.backup</string>
<key>ProgramArguments</key>
<array>
<string>/Users/me/bin/backup.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
</dict>
</plist>
Save it as ~/Library/LaunchAgents/com.me.backup.plist, then load it:
launchctl load ~/Library/LaunchAgents/com.me.backup.plist
The job is now registered with launchd. It runs as the user, on the schedule, with the right environment, and survives sleep/wake cycles in most configurations. The trade-off is that the plist format is XML and less obvious than a crontab line, and the error mode is “the job did not run” with no log to grep.
When to use which
Use crontab when the job is a one-off, lives on a Mac laptop temporarily, is the same job that lives on a Linux box, and the failure mode (a missed run) is acceptable. The 2 a.m. backup script that runs while the laptop is open is a crontab. The path of least surprise for engineers who came from Linux.
Use launchd when the job is critical, has to survive sleep/wake, needs to run in the user’s GUI session, has to start at boot (as a daemon), or has to react to events (file changes, network state, mount events). The script that pulls the latest git commit on a custom trigger is a launchd agent. The daemon that ships with a Mac app is a launchd daemon.
A useful rule of thumb: if the job would be a systemd timer on Linux, it should be a launchd plist on macOS. If it would be a crontab on Linux, it is fine as a crontab on macOS, with the caveats above.
A working launchd plist you can copy
For a job that runs every 15 minutes and logs to a file, with a few of the gotchas already handled:
<?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.me.sync</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>/Users/me/bin/sync.sh >> /Users/me/logs/sync.log 2>&1</string>
</array>
<key>StartInterval</key>
<integer>900</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/me/logs/sync.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/me/logs/sync.err.log</string>
</dict>
</plist>
The interesting parts: ProgramArguments is the right way to call a script (no shell parsing surprises), StartInterval is in seconds (900 = 15 minutes), RunAtLoad runs the job once at load time, and the log paths are explicit so the output is not lost. The script runs under bash -lc, which sources the user’s login profile, so PATH and any nvm or pyenv shims are available.
The verification:
launchctl load ~/Library/LaunchAgents/com.me.sync.plist
launchctl list | grep com.me.sync
# Manually trigger once to confirm
launchctl start com.me.sync
If the job appears in launchctl list with a recent PID and a zero exit code, it is running. If it does not appear, the plist is malformed and console.app will have the error.
The macOS sleep problem and the right fix
The cron daemon does not replay missed jobs. launchd does, depending on configuration. The relevant plist keys are StartCalendarInterval (the cron-style schedule) and WakeFromSleep plus KeepAlive for jobs that have to catch up after a wake event.
For a job that should run at 9 a.m. regardless of when the laptop was awake, the right shape is:
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
</array>
If the laptop was asleep at 9 a.m. and woke at 10 a.m., launchd fires the job at 10 a.m. (within a tolerance window). If the laptop was awake at 9 a.m., the job fires on schedule. This is the behavior cron does not give you.
For a job that has to run every 15 minutes while the laptop is awake, use StartInterval instead of StartCalendarInterval. launchd will fire it on the wall clock, not the uptime clock, and the sleep gap is handled the same way.
The bottom line: crontab on a Mac is the right choice for a small set of cases. For everything that has to keep running, launchd is the system the OS actually maintains, and the plist is the file that survives the next macOS update.
How this fits the rest of the stack
A scheduled job is also a hosting cost — every cron run costs CPU time, every job that emails or writes to a database costs storage, and every failed retry costs bandwidth. The team’s mental model for the job’s cost is the frequency, the runtime, the storage, and the bandwidth. The RunxBuild hosting calculator is the right place to model that — pick the job frequency, the runtime size, the database tier, and the storage, and the calculator shows what the scheduled job costs at the team’s actual usage.
Useful related references:
FAQ
Does crontab still work on macOS?
Yes, as of Sequoia. crontab -e, crontab -l, and crontab -r all work. The cron daemon runs and executes jobs. The system no longer guarantees the daemon starts on boot in all configurations, and Apple has been recommending launchd as the replacement for over a decade.
Where is the macOS crontab stored?
In /var/at/tabs/<user> (the spool used by cron on macOS). The user-level crontab is not in the home directory; it is in the system spool.
How do I see all cron jobs on my Mac?
crontab -l for the current user. For other users, sudo crontab -u <user> -l. For launchd jobs, launchctl list shows the loaded ones, and the plists live in ~/Library/LaunchAgents/, /Library/LaunchAgents/, /Library/LaunchDaemons/, and /System/Library/LaunchDaemons/.
Why does my mac crontab job fail with “command not found”?
Cron runs with a stripped PATH (just /usr/bin:/bin). Set the full path in the crontab, or set PATH= at the top of the crontab to the path your interactive shell uses (echo $PATH in Terminal to see it).
Should I use crontab or launchd on a Mac?
For a one-off backup that runs while the laptop is open, crontab is fine. For anything that has to survive sleep, run at boot, or react to events, launchd is the right choice. Apple has been recommending launchd for over a decade.
How do I debug a crontab job that is not running?
Add MAILTO=your@email at the top of the crontab to receive output, or redirect to a log file (>> /tmp/cron.log 2>&1). Check /var/log/system.log for cron-related lines. Run the script by hand with the same environment cron uses (env -i /bin/bash) to reproduce the failure.