How-to
Automating Reporting WorkflowsDeep dive

Schedule a Python Excel Script on macOS with launchd

Run a Python report on a Mac on a schedule the way macOS intends: a launch agent property list generated and checked with plistlib, StartCalendarInterval for weekly and weekday schedules, the virtualenv's Python by absolute path, logs to files, what happens when the Mac was asleep, privacy permissions for Documents and Desktop, and loading, testing and removing the job with launchctl.

Plenty of reporting automation runs on an analyst's or a small team's Mac: the script that builds the Monday numbers from an export in ~/Reports, the one that refreshes a workbook every morning before the stand-up. macOS still ships cron, but Apple's supported scheduler is launchd, and it behaves better on a laptop: a job scheduled while the Mac was asleep runs when it wakes instead of being skipped. A launchd job is a small XML property list describing what to run and when. This page, part of Scheduling Python Excel Scripts with Cron, generates that file from Python — so the schedule is code, not hand-edited XML — checks it, and shows the launchctl commands that load, test and remove it.

Prerequisites

The report is a Python script in a project folder with its own virtual environment — ~/reports/weekly_sales/.venv — so the scheduled job uses exactly the packages the script was tested with. Generating and checking the property list needs only the standard library, which is how the examples below ran; the launchctl commands in Steps 5 and 6 are macOS-only and are shown for completeness.

Where launchd jobs live Three layers. A user LaunchAgent in the home folder runs as that user while they are logged in, which suits personal reports. A LaunchDaemon in /Library runs as root at boot without anyone logged in. Both are property list files that launchctl loads. ~/Library/LaunchAgents runs as you, while you are logged in — reports on your Mac /Library/LaunchDaemons runs as root at boot, nobody logged in — a shared machine launchctl bootstrap, kickstart, print, bootout

Step 1 — Describe the job in Python

A launch agent is a dictionary: a unique Label, the command as ProgramArguments, the schedule, and where to write output. Build it as a Python dictionary and write it with plistlib, which produces valid XML with the right types:

Python
import plistlib
from pathlib import Path

HOME = Path("/Users/analyst")
PROJECT = HOME / "reports" / "weekly_sales"
LABEL = "com.acme.reports.weekly-sales"

job = {
    "Label": LABEL,
    "ProgramArguments": [str(PROJECT / ".venv" / "bin" / "python"), str(PROJECT / "build_report.py")],
    "WorkingDirectory": str(PROJECT),
    "StartCalendarInterval": [{"Weekday": 1, "Hour": 7, "Minute": 0}],
    "StandardOutPath": str(PROJECT / "logs" / "weekly_sales.out.log"),
    "StandardErrorPath": str(PROJECT / "logs" / "weekly_sales.err.log"),
    "EnvironmentVariables": {"REPORT_ENV": "production", "PYTHONUNBUFFERED": "1"},
    "ProcessType": "Background",
}

plist_path = Path(f"{LABEL}.plist")
with plist_path.open("wb") as f:
    plistlib.dump(job, f)
print(plist_path.read_text()[:420])

The interpreter is given by absolute path: launchd does not read shell profiles, so python3 on its own would mean the system Python with none of the report's packages. Pointing at the virtualenv's bin/python runs the script with its own dependencies without any activation. WorkingDirectory makes relative paths in the script — exports/latest.xlsx — resolve inside the project. PYTHONUNBUFFERED flushes print output straight to the log file. The label is conventionally reverse-DNS and must be unique on the Mac; the file is named after it.

Step 2 — Choose the schedule

StartCalendarInterval takes a dictionary of Minute, Hour, Day, Weekday and Month; missing keys mean "every". Weekday counts from 0 or 7 for Sunday, so 1 is Monday. A list of dictionaries gives several times — which is also how to express "weekdays", because there are no ranges:

Python
def calendar_intervals(hour, minute, weekdays=None):
    if weekdays is None:
        return [{"Hour": hour, "Minute": minute}]
    return [{"Weekday": d, "Hour": hour, "Minute": minute} for d in weekdays]

every_morning = calendar_intervals(6, 30)
weekday_mornings = calendar_intervals(6, 30, weekdays=range(1, 6))
monday_and_thursday = calendar_intervals(7, 0, weekdays=[1, 4])
print(every_morning)
print(weekday_mornings)
print(len(monday_and_thursday), "entries:", monday_and_thursday)

A cron line such as 30 6 * * 1-5 becomes five dictionaries, one per weekday. Times are in the Mac's local time zone and follow daylight-saving changes. For "every 15 minutes" use StartInterval with a number of seconds instead — it counts from when the job was loaded rather than from the clock.

A cron line and its launchd equivalent On the left the cron expression 30 6 * * 1-5 split into its fields. On the right the StartCalendarInterval list with one entry per weekday, Monday to Friday, each at hour 6 minute 30. A B C 1 minute hour weekday 2 30 6 1-5 A B C 1 Weekday Hour Minute 2 1 6 30 3 2 6 30 4 … … … 5 5 6 30 before after calendar_intervals

Step 3 — Check the file before loading it

A launchd file with a wrong key or type is ignored or rejected when loaded, often with a terse message. Read the file back and check the parts that matter:

Python
loaded = plistlib.loads(plist_path.read_bytes())
problems = []
if not Path(loaded["ProgramArguments"][0]).is_absolute():
    problems.append("interpreter path must be absolute")
for entry in loaded.get("StartCalendarInterval", []):
    unknown = set(entry) - {"Minute", "Hour", "Day", "Weekday", "Month"}
    if unknown:
        problems.append(f"unknown schedule keys {unknown}")
    if not 0 <= entry.get("Weekday", 0) <= 7:
        problems.append(f"bad weekday {entry['Weekday']}")
if Path(loaded["StandardOutPath"]).parent.name != "logs":
    problems.append("log files should go to the project's logs folder")
print(loaded["Label"], "->", problems or "looks valid")

On a Mac, plutil -lint checks the XML syntax as well. The log folder must exist before the job runs — launchd creates the log files but not missing directories — so the setup script should create logs/ too.

Step 4 — What happens when the Mac is asleep or off

This is the main reason to use launchd rather than cron on a laptop. If the Mac is asleep at 07:00 on Monday, launchd runs the job when it wakes; if several scheduled times were missed during one sleep, they are combined into a single run. If the Mac was shut down, the missed run does not happen. The script therefore should not assume it runs at exactly 07:00 — derive the report period from the data or from the calendar, not from the time the script happened to start:

Python
from datetime import date, timedelta

def report_week(today: date) -> date:
    return today - timedelta(days=today.weekday())      # the Monday of this week

for run_day in (date(2026, 9, 28), date(2026, 9, 29), date(2026, 10, 1)):
    print(run_day, "->", report_week(run_day))

Whether the job runs on Monday at 07:00 or on Tuesday when the lid is opened, it builds the report for the week starting Monday 28 September. A late run of the right report is useful; an on-time run of the wrong one is not. Backfill Excel Reports for Past Dates covers catching up after longer gaps.

A missed Monday on a laptop A timeline. The job is scheduled for Monday 07:00 while the laptop is asleep; nothing runs then; when the lid opens at 08:40 launchd runs the job once, and the script builds the report for the week of 28 September. Mon 07:00 scheduled — Mac asleep Mon 08:40 lid opened Mon 08:40 launchd runs the job once Report week of 28 September

Step 5 — Load, test and inspect the job

Copy the file to ~/Library/LaunchAgents and load it into your login session with launchctl bootstrap. kickstart runs it immediately, which is the way to test it without waiting for Monday:

Bash
mkdir -p ~/reports/weekly_sales/logs
cp com.acme.reports.weekly-sales.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.acme.reports.weekly-sales.plist
launchctl kickstart -k gui/$(id -u)/com.acme.reports.weekly-sales
launchctl print gui/$(id -u)/com.acme.reports.weekly-sales | grep -E "state|last exit code"
tail ~/reports/weekly_sales/logs/weekly_sales.err.log

launchctl print shows whether the job is loaded and the exit code of its last run; a non-zero code sends you to the error log. The older launchctl load and unload commands still work but are deprecated in favour of bootstrap and bootout.

Step 6 — Privacy permissions and removal

Recent macOS versions protect folders such as Documents, Desktop and Downloads. A script that reads an export from ~/Downloads works when run from Terminal — which has been granted access — and fails under launchd with "Operation not permitted", because the job's interpreter has not. Either keep the report's files outside the protected folders, in a project directory like ~/reports, or grant the virtualenv's Python Full Disk Access in System Settings → Privacy & Security. The first is simpler and safer. To remove the job:

Bash
launchctl bootout gui/$(id -u)/com.acme.reports.weekly-sales
rm ~/Library/LaunchAgents/com.acme.reports.weekly-sales.plist

Common pitfalls

  • python3 without a path. launchd has a minimal environment; use the virtualenv's interpreter by absolute path.
  • Missing log directory. launchd will not create it; the job fails before the script starts.
  • Files in Documents, Desktop or Downloads. Privacy protection blocks the job; move them or grant Full Disk Access.
  • Expecting runs while shut down. Sleep is handled, power-off is not; derive the report period from the calendar.
  • A LaunchAgent on a shared server. Agents run only while the user is logged in; use a LaunchDaemon or a proper server scheduler.

Conclusion

Describe the job as a dictionary and write it with plistlib: a unique label, the virtualenv's Python and the script by absolute path, a working directory, StartCalendarInterval entries for the schedule and log file paths. Check the file before loading it, bootstrap it into your session with launchctl, test it with kickstart, keep the report's files out of privacy-protected folders, and write the script so a late run after sleep still produces the right period's report.

Frequently asked questions

Should I use cron or launchd on a Mac? launchd. It is Apple's supported scheduler, and it runs a job missed while the Mac was asleep as soon as it wakes, whereas cron skips it.

How do I run a Python script every Monday at 7am on macOS? Create a LaunchAgent plist with ProgramArguments pointing at your Python interpreter and script, and StartCalendarInterval set to Weekday 1, Hour 7, Minute 0. Load it with launchctl bootstrap gui/$(id -u) and the plist path.

Why does my launchd job fail with "Operation not permitted"? The script is reading or writing a privacy-protected folder such as Documents, Desktop or Downloads, and the Python interpreter the job runs has not been granted access. Move the files elsewhere or give that interpreter Full Disk Access.

How do I test a launchd job without waiting for its schedule? Run launchctl kickstart -k gui/$(id -u)/your.label, then check launchctl print for the last exit code and read the log files named in the plist.

More on scheduling & orchestration

More on scheduling & orchestration →