Schedule a Python Excel Script on macOS with launchd
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.
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:
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:
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.
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:
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:
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.
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:
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:
launchctl bootout gui/$(id -u)/com.acme.reports.weekly-sales
rm ~/Library/LaunchAgents/com.acme.reports.weekly-sales.plist
Common pitfalls
python3without 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.
Related
- Up one level: Scheduling Python Excel Scripts with Cron — every way to run reports on a schedule.
- Run a Python Excel Script on Windows Task Scheduler — the Windows equivalent.
- Schedule an Excel Report with systemd Timers — the Linux equivalent.
- Log Python Excel Script Output to a File — what to write to those log files.
More on scheduling & orchestration
- How-toDownload New Excel Files from an SFTP Server
- How-toRead Excel Attachments into a Database Nightly
- How-toRefresh an Excel Report from a Database on a Schedule
- How-toRun a Report When an Excel File Changes in OneDrive
- How-toSend Report Emails at Each Recipient's Local Time
- How-toDetect a Scheduled Report That Did Not Run