DEV Community

Hidenari Fujiwara
Hidenari Fujiwara

Posted on

launchd: The macOS Scheduler You Already Have (A Practical Tutorial)

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:

  1. 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.
  2. 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>
Enter fullscreen mode Exit fullscreen mode

Four things to notice:

  • Label must be unique on your machine and should match the filename.
  • ProgramArguments is an array: binary first, then each argument as its own <string>. This is the #1 source of silent failures — python3 script.py as a single string will not work.
  • StartCalendarInterval is cron's schedule, but readable. Omit Weekday to 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
    ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 StartCalendarInterval jobs 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)