macOS launchd: the actual replacement for cron when you want a recurring task on your Mac

You write a Python script that needs to run every fifteen minutes on your Mac. You add a line to crontab -e, save, walk away. Two days later you check and notice it never ran. The cron file is fine. The script is fine. macOS just… doesn’t actually use cron the way you expect. The proper macOS replacement is launchd, and once you grok how it works, it’s genuinely better — but the on-ramp is steep and the docs scattered.

Here’s a working LaunchAgent recipe with the gotchas that bite first-time users.

LaunchAgent vs LaunchDaemon — pick the right one

  • LaunchAgent — runs in your user session, only when you’re logged in. Lives in ~/Library/LaunchAgents/ for per-user, or /Library/LaunchAgents/ for “all users.” Has access to the Keychain, the Wi-Fi, your Documents folder, your environment — all the things a script you’d run in Terminal can see.
  • LaunchDaemon — runs as root, regardless of login state. Lives in /Library/LaunchDaemons/. Cannot show GUI, cannot read your home directory’s protected files, cannot use your SSH agent. Use only when the task genuinely needs to run when no one’s logged in (background sync, log shipping, server-style cron jobs on a Mac mini server).

Most “I want a recurring task on my Mac” cases are LaunchAgents, not LaunchDaemons. Pick that unless you have a specific reason otherwise.

A working plist

Save this as ~/Library/LaunchAgents/com.you.daily-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.daily-cleanup</string>

    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/env</string>
        <string>python3</string>
        <string>/Users/you/scripts/daily-cleanup.py</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>3</integer>
        <key>Minute</key>
        <integer>15</integer>
    </dict>

    <key>StandardOutPath</key>
    <string>/tmp/daily-cleanup.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/daily-cleanup.log</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>

That runs the script every day at 3:15 AM, logs stdout and stderr to /tmp/daily-cleanup.log, and gives the job a sane PATH so Homebrew binaries are reachable.

Loading and unloading

# Modern (macOS 10.10+) syntax
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.daily-cleanup.plist

# Run it once immediately to test (don't wait until 3:15 AM)
launchctl kickstart -p gui/$(id -u)/com.you.daily-cleanup

# Unload (you've changed the plist and want to reload, or you're done)
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.you.daily-cleanup.plist

# Reload after editing
launchctl bootout gui/$(id -u)/com.you.daily-cleanup
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.daily-cleanup.plist

The older launchctl load / launchctl unload syntax still works for compatibility, but bootstrap / bootout is what Apple wants you to use now and gives clearer error messages when something’s wrong.

The four gotchas that bite everyone

  1. Your script’s PATH is empty by default. launchd doesn’t read your shell rc files. If your script calls git or jq or node by bare name, it’ll fail with “command not found.” Either set EnvironmentVariables.PATH in the plist (as above), or call binaries by absolute path inside the script.
  2. The plist must have valid XML. One missing closing tag, one wrong DTD reference, and launchctl bootstrap silently does nothing. Validate first: plutil -lint ~/Library/LaunchAgents/com.you.daily-cleanup.plist. If it says “OK”, proceed; if it returns an error line number, fix that.
  3. Your laptop’s lid being closed counts as “not logged in” for some intents. If the laptop is asleep when the scheduled time hits, the job runs when you next wake it, not at the scheduled time. StartCalendarInterval fires on next-wake; StartInterval (run every N seconds) does too. For a 3:15 AM script, this means it actually runs at 9:00 AM when you open the laptop. Mostly fine. If you need real “guaranteed at 3:15 AM” behavior, you need caffeinate or a power-management workaround, or just run the task on a server.
  4. Permissions and TCC. macOS’s “Transparency, Consent, and Control” framework blocks scripts from accessing things like Calendar, Contacts, or your Photos library unless the parent process has been granted permission. A LaunchAgent doesn’t inherit your Terminal’s grants. First time the script tries to read ~/Library/Calendars/, it’ll fail silently or get a “permission denied” instead of a prompt. Fix: grant the parent process (usually /usr/bin/env or your Python binary) full disk access in System Settings → Privacy & Security → Full Disk Access. Annoying but necessary.

Common interval patterns

  • Every N seconds: use StartInterval instead of StartCalendarInterval. <key>StartInterval</key><integer>900</integer> = every 15 minutes.
  • At a specific time on weekdays only: add <key>Weekday</key><integer>1</integer> through 5 within the StartCalendarInterval dict (Sunday=0, Saturday=6).
  • On a specific event (file change, network up, login): use WatchPaths, QueueDirectories, or KeepAlive with conditions. launchd is designed for these — cron can’t do them at all.

Once you’ve written one plist, the second one takes 90 seconds. The cron / Mac mismatch isn’t a flaw in macOS — it’s that the launchd model is genuinely better suited to the laptop-that-sleeps reality of modern computing. Cron was written for always-on Unix servers; launchd was written for the machine in your bag.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.