How cron Works on macOS Today

cron on macOS is provided by the system's built-in crond, which is itself launched by launchd via /System/Library/LaunchDaemons/com.vix.cron.plist. You can still use crontab -e and all standard cron syntax, and jobs will run, but with significant caveats.

The biggest operational problem is that cron jobs on macOS do not run if the machine is asleep. There is no wake-on-schedule mechanism tied to cron entries. If your Mac mini server is in sleep mode at 2:00 AM when a backup cron job fires, nothing happens and nothing is logged. You will not get an error. The job simply does not execute.

A second problem is Full Disk Access. Since macOS 10.14 Mojave, processes that touch protected directories like ~/Desktop, ~/Documents, ~/Library/Mail, and Time Machine volumes require the Full Disk Access entitlement in System Settings. crond does not have this entitlement by default, so cron jobs silently fail when they try to read or write those paths. You can grant Full Disk Access to /usr/sbin/cron in System Settings > Privacy and Security, but this requires manual intervention on every machine and does not survive major OS upgrades cleanly.

Environment variables are another friction point. cron jobs inherit a minimal shell environment. PATH is typically /usr/bin:/bin, which means tools installed via Homebrew at /usr/local/bin or /opt/homebrew/bin are not found unless you hardcode the full path or set PATH explicitly at the top of the crontab.

# List current crontab
crontab -l

# Edit crontab (uses $EDITOR or vi)
crontab -e

# Example entry - runs at 2am, but will silently skip if Mac is asleep
0 2 * * * /usr/local/bin/rsync -avz /data/ user@backup:/data/

How launchd Works: Daemons vs Agents

launchd operates on property list files (.plist in XML or binary format) placed in specific directories. The placement determines who the job runs as and when it is loaded.

/Library/LaunchDaemons/ - system-wide, runs as root, loads at boot before login /Library/LaunchAgents/ - system-wide, runs as the logged-in user, loads at login ~/Library/LaunchAgents/ - per-user, runs as that user, loads at that user's login /System/Library/LaunchDaemons/ and /System/Library/LaunchAgents/ are Apple's own plists and are sealed by System Integrity Protection - do not touch them.

For scheduled tasks specifically, launchd uses the StartCalendarInterval key, which looks superficially like cron syntax but works differently. It also supports StartInterval for repeating intervals in seconds, and WatchPaths to trigger on filesystem changes, which cron cannot do at all.

The critical operational difference: launchd jobs with StartCalendarInterval will fire as soon as the machine wakes if the scheduled time was missed during sleep. Set a job to run at 03:00, the machine sleeps from 02:00 to 04:30, and at 04:30 on wake the job runs immediately. This catch-up behavior is controlled by the launchd engine itself and requires no additional configuration.

# Check what launchd has loaded for the current user
launchctl list

# Filter to see only your custom agents
launchctl list | grep com.mycompany

# Load a new plist without rebooting
launchctl load ~/Library/LaunchAgents/com.mycompany.backup.plist

# Unload (disable) a plist
launchctl unload ~/Library/LaunchAgents/com.mycompany.backup.plist

# On macOS 10.11+ you should prefer the newer bootstrap/bootout syntax
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.mycompany.backup.plist
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.mycompany.backup.plist

Writing a Minimal launchd Plist

A launchd plist for a scheduled job has a small number of required keys and several optional ones that matter in practice. The Label key must be unique across the system and should follow reverse-DNS naming. If you are naming your automation project or internal tooling, keeping that reverse-DNS label consistent with your actual domain matters for auditability - something worth considering if you are registering a domain for the project at a service like nicename.me to keep the namespace clean.

The ProgramArguments key takes an array, not a shell string. You cannot use pipes, redirects, or shell expansion directly - you need a wrapper script or use StandardOutPath and StandardErrorPath keys to capture output.

StartCalendarInterval accepts a dictionary or array of dictionaries. Keys are Minute, Hour, Day, Month, Weekday, all optional. Omitting a key means 'every' for that unit, exactly like cron's asterisk.

The EnvironmentVariables key solves the PATH problem cleanly. Define exactly what the job needs. This is more explicit than cron's inherited-and-trimmed environment and easier to reproduce across machines.





    Label
    com.mycompany.nightly-backup

    ProgramArguments
    
        /usr/local/bin/rsync
        -avz
        /Users/admin/data/
        backup@192.168.1.10:/backups/
    

    StartCalendarInterval
    
        Hour
        2
        Minute
        0
    

    EnvironmentVariables
    
        PATH
        /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
    

    StandardOutPath
    /var/log/nightly-backup.log

    StandardErrorPath
    /var/log/nightly-backup.err

    RunAtLoad
    

// advertisement

Validating and Debugging launchd Plists

plutil is your first validation step. It checks XML structure and plist syntax but does not validate launchd-specific keys. For launchd-aware validation, launchctl itself will report errors on load.

Once a job is loaded, launchctl list shows its PID (if running) and last exit code. Exit code -1 means launchd could not start the process at all, usually a bad path or permissions issue. Exit code 1 or higher is the process itself failing.

The unified logging system replaced /var/log/system.log in macOS 10.12. To see launchd-related messages in real time, use log stream with a predicate. This is significantly more useful than grepping syslog and shows both launchd's own messages and anything the job writes to stdout if you have not set StandardOutPath.

For jobs that are not firing when expected, check launchctl print to see the full state of a service, including the next scheduled run time.

# Validate plist XML structure
plutil -lint ~/Library/LaunchAgents/com.mycompany.backup.plist

# Check job status and last exit code
launchctl list com.mycompany.nightly-backup

# Stream launchd log messages in real time
log stream --predicate 'subsystem == "com.apple.launchd"' --level debug

# Stream messages from your specific job
log stream --predicate 'processImagePath contains "rsync"' --level debug

# Print full service state including next scheduled fire time
launchctl print gui/$(id -u)/com.mycompany.nightly-backup

# Manually trigger a job immediately for testing
launchctl kickstart -k gui/$(id -u)/com.mycompany.nightly-backup

Where cron Still Makes Sense on macOS

cron is not completely obsolete on macOS. There are two scenarios where it remains the practical choice.

First: cross-platform scripts and config management. If you manage mixed fleets with Ansible, Chef, or similar tools and you are already using the cron module to schedule tasks on Linux hosts, extending the same crontab entries to macOS nodes with the same module is operationally simpler than maintaining a parallel launchd plist workflow. The jobs work. The tradeoffs (no sleep wakeup, limited FDA) may be acceptable for headless Mac servers that do not sleep.

Second: quick personal automation where you own the machine and know it stays awake. A Mac Pro or Mac Studio used as an always-on server with sleep disabled in System Settings > Energy Saver is functionally equivalent to a Linux server for cron purposes. In that context, cron syntax is faster to write and universally understood by anyone who reads the crontab.

For DevOps teams building more sophisticated automation pipelines - where jobs need dependency ordering, retry logic, or integration with observability platforms - neither cron nor raw launchd plists scale well. In those cases, tools in the DevOps automation space like taskbotshub.ai handle the orchestration layer above launchd and give you audit trails and failure alerting that launchd alone does not provide.

# Disable sleep on a headless Mac server (run as root)
sudo pmset -a sleep 0
sudo pmset -a disksleep 0
sudo pmset -a displaysleep 0

# Verify current power settings
pmset -g

Key Behavioral Differences at a Glance

Sleep handling: launchd fires missed jobs on wake, cron does not. This is the single most important difference for Mac laptops and machines with sleep enabled.

Full Disk Access: launchd daemons running as root in /Library/LaunchDaemons have FDA by default. LaunchAgents running as a user need FDA granted to the specific binary being called, same as cron, but the plist model makes it easier to isolate which binary needs the entitlement.

Logging: launchd integrates with the unified logging system. cron writes to syslog/ASL with less structured output. Both work, but launchctl and log stream give you better visibility into launchd jobs.

Shell features: cron entries run in a minimal sh environment and can use basic shell syntax inline. launchd ProgramArguments takes an exec array - no shell interpolation. If you need shell features, call a script explicitly.

Reload behavior: editing a crontab with crontab -e takes effect immediately. A modified launchd plist requires unload/load or bootout/bootstrap to take effect. This is the main friction point when iterating on launchd jobs during development.

Multiple schedules: cron handles multiple schedule entries for one job trivially - add another line. launchd requires StartCalendarInterval to be an array of dictionaries for multiple firing times.

Throttle interval: launchd has ThrottleInterval (default 10 seconds) that prevents a job from being restarted more frequently than that value. There is no cron equivalent since cron's minimum granularity is one minute.

# launchd plist with multiple schedule times (6am and 6pm)
# StartCalendarInterval takes an array for multiple entries

# In the plist:
# StartCalendarInterval
# 
#     Hour6Minute0
#     Hour18Minute0
# 

# Equivalent crontab entries:
0 6 * * * /path/to/script.sh
0 18 * * * /path/to/script.sh
// advertisement

Tooling to Reduce Plist Friction

The main complaint about launchd from engineers coming from Linux is the verbosity of XML plists compared to a crontab line. Several tools address this.

launchd-generator and LaunchControl (a GUI) both exist, but for command-line workflows on a managed fleet, the most practical approach is a template in your config management system. An Ansible task using the template module to write a validated plist from variables is reproducible and auditable in a way that neither manual crontab editing nor GUI tools are.

plist2cron and cron2plist scripts exist on GitHub but are not maintained to current macOS versions. Do not rely on them for production.

For validation in CI before deploying a plist to a fleet, plutil -lint in a pipeline step catches XML errors. You can also use xmllint against Apple's published DTD for stricter checking. On a test machine, launchctl load followed by launchctl list and checking the exit code gives you a functional smoke test.

log show is useful for post-hoc job auditing. It queries the unified log store, which retains data for several days by default, and lets you confirm a job ran at its scheduled time even if you were not watching the machine.

# Ansible task to deploy a launchd agent plist
# tasks/main.yml snippet:
# - name: Deploy backup LaunchAgent
#   template:
#     src: com.mycompany.backup.plist.j2
#     dest: "/Users/{{ ansible_user }}/Library/LaunchAgents/com.mycompany.backup.plist"
#     mode: '0644'
#   notify: reload launchd agent

# Handler:
# - name: reload launchd agent
#   command: >
#     launchctl bootout gui/{{ ansible_user_uid }}
#     /Users/{{ ansible_user }}/Library/LaunchAgents/com.mycompany.backup.plist
#   ignore_errors: yes
# - command: >
#     launchctl bootstrap gui/{{ ansible_user_uid }}
#     /Users/{{ ansible_user }}/Library/LaunchAgents/com.mycompany.backup.plist

# Check job ran successfully in the past 24 hours
log show --last 24h --predicate 'processImagePath contains "rsync"' | grep -i "exit\|error\|start"