Cron exists on macOS, but Apple has been quietly steering everyone toward launchd for twenty years. Here's a hands-on guide to running your scripts on a schedule — with two real jobs I run every week, and the mistakes I made so you don't have to.
I run two scheduled jobs on my Mac that quietly do work for me:
- Every weekday at 15:45 (after the Tokyo market close), a job records prices for a paper-trading portfolio and sends me a macOS notification with the day's P&L.
- Every Monday at 09:00, a job pulls my dev.to article stats into a CSV so I can see trends without checking dashboards.
Total infrastructure cost: ¥0. No cloud, no serverless, no Docker. Just launchd, the init system that is already running on your Mac. This tutorial builds up a working job from scratch.
Step 1: Understand the one file that matters
A launchd job is defined by a single XML file (a "property list" / plist) in ~/Library/LaunchAgents/. The naming convention is reverse-DNS: com.yourname.yourjob.plist.
Here is a minimal, real one — my weekly stats job:
<?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.devto-stats</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/you/projects/writing/scripts/devto_stats.py</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key><integer>1</integer> <!-- Monday -->
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/you/projects/writing/data/launchd.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/projects/writing/data/launchd.log</string>
</dict>
</plist>
Four things to notice:
-
Labelmust be unique on your machine and should match the filename. -
ProgramArgumentsis an array: binary first, then each argument as its own<string>. This is the #1 source of silent failures —python3 script.pyas a single string will not work. -
StartCalendarIntervalis cron's schedule, but readable. OmitWeekdayto run daily; use an array of dicts for multiple times. - Always set the log paths. launchd failures are invisible without them.
Step 2: Load it, run it, prove it works
# Load the job (modern syntax; "load" is the deprecated spelling)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.devto-stats.plist
# Is it registered?
launchctl list | grep devto-stats
# Don't wait for Monday — force a run right now:
launchctl kickstart gui/$(id -u)/com.example.devto-stats
# Watch what happened:
tail -f ~/projects/writing/data/launchd.log
To unload: launchctl bootout gui/$(id -u)/com.example.devto-stats.
kickstart is the debugging superpower. Edit script → kickstart → read log, in a tight loop, instead of waiting for the schedule to come around.
Step 3: Write scripts that survive being run by a robot
A script that works in your terminal will betray you under launchd, because launchd gives it a minimal environment: no shell profile, no PATH additions, no working directory you expect, no API keys you exported once in .zshrc. Rules I follow after learning each one the hard way:
Absolute paths for everything.
#!/bin/zsh
REPO="/Users/you/projects/trading" # not ~/projects, not $PWD
PY="$REPO/.venv/bin/python" # the venv's python, not "python3"
cd "$REPO" || exit 1
Read secrets from files, not from the environment. My stats script reads its API key from a git-ignored file and exits politely when it's missing, so the job is safe to install before it's configured:
KEY_FILE = ROOT / ".devto_api_key"
def main() -> None:
if not KEY_FILE.exists():
print("API key not set. Skipping.")
return
key = KEY_FILE.read_text().strip()
...
Make re-runs harmless (idempotency). Scheduled jobs get run twice — by you while debugging, by launchd after a missed schedule. My market-tracking job records positions only if they haven't been recorded already:
existing = {p["ticker"] for p in journal.open_positions()}
new_rows = rows[~rows["ticker"].isin(existing)] # re-running adds nothing
Append to logs with a timestamp header, so the log file reads as a history:
{
echo "===== $(date +%F) $(date +%H:%M) ====="
"$PY" tracker.py report
echo ""
} >> "$LOG"
Step 4: Close the loop with notifications
A scheduled job you never hear from is a job you'll forget exists — until it's been broken for a month. The cheapest feedback channel on macOS is a notification, one line of AppleScript away:
summary=$(grep "Portfolio" "$LOG" | tail -1)
osascript -e "display notification \"${summary:-Job failed, check log}\" \
with title \"Daily tracker\"" 2>/dev/null || true
Note the fallback text: if the report failed and $summary is empty, the notification says so instead of silently not appearing. The || true keeps a notification failure from failing the whole job.
Step 5: Know when not to use launchd
Honest limitations, from running these jobs for a while:
-
Your Mac must be awake. launchd runs missed
StartCalendarIntervaljobs when the machine wakes — but only the most recent miss, and "sometime after wake" may be too late for time-sensitive work. My 15:45 market job assumes the laptop is open in the afternoon; that assumption holds for me, but a server it is not. - No retries, no alerting, no history UI. You are the monitoring system (hence Step 4).
- If the job must run at an exact minute regardless of your laptop's mood, use a cloud scheduler. The point of launchd is the enormous class of personal automation where "today, roughly on time, for free, with my local files and credentials" is exactly right.
The part where this compounds
Each job is small. The pattern is what compounds: every repetitive check in your life — a dashboard you open, a number you copy into a spreadsheet, a daily record you keep meaning to keep — is a candidate for a 30-line script and a plist. I now write the script, wire the plist, watch it via notifications for a week, and forget about it.
My laptop does the remembering. That's the whole trick.
Both jobs in this article are simplified from real ones I run: a paper-trading tracker for a rule-based stock research project (build log) and a writing-stats collector. Simplifications don't change behavior.
Top comments (0)