Scheduled Scripts on macOS
On macOS,you can run a background job on a timed schedule in several different ways, although some of them are deprecated:
launchd
cron
at
jobs (deprecated)periodic
jobs (deprecated)
If you are new to working on Macs and need to run a scheduled task, your first instinct might be to reach for cron
, although this is not necessarily the right tool for the job.
According to Apple’s archived documentation on scheduling timed jobs:
The preferred way to add a timed job is to use
launchd
[…] Although it is still supported,cron
is not a recommended solution. It has been deprecated in favor oflaunchd
. [1]
In this post, I will outline some examples of where launchd
indeed outshines cron
.
Scenario
To start, let’s say you have a Python script called convert_txt_to_md.py
that points at a path and recursively goes through all the directories and changes all the text files to markdown:
import os
directory = '/Users/andrew.katz/notes/superwhisper/recordings'
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith(".txt"):
txt_path = os.path.join(root, file)
md_path = txt_path[:-4] + '.md'
os.rename(txt_path, md_path)
For context, I use superwhisper for dictation which outputs a transcript of my voice recording as text files to my Obsidian vault (/notes
). Since Obsidian can’t search text files, I convert them to markdown using this script which allows Obsidian to natively search for them.
![[Pasted image 20240811131454.png]]
To create a scheduled job using launchd
, you need to create a Launch Agent. Launch Agents run once the user has logged in with standard user permissions, and they may interact with the user session. Third-party launch agents live in /Library/LaunchAgents
or ~/Library/LaunchAgents
.
Creating your Launch Agent involves creating a property list (plist) file that you will add to ~Library/LaunchAgents
. A plist is an XML, JSON, or binary file that contains key/value pairs that store configuration information, settings, serialized objects, and more.
Plists are usually named with com.companyName.itemName
, so we are going to go with com.ackatz.converttxttomd
as the name/label.
Using nano ~/Library/LaunchAgents/com.ackatz.converttxttomd.plist
, I create the plist using the following information:
<?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.ackatz.converttxttomd</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/andrew.katz/convert_txt_to_md.py</string>
</array>
<key>StartInterval</key>
<integer>3600</integer> <!-- Runs every hour -->
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
I find plists a lot easier to read than cron expressions/crontab syntax
To describe some of the keys above:
Key | Behavior |
---|---|
Label | name/label of your Launch Agent |
Program Arguments | path to the executable script or binary + args |
Start Interval | run job every n seconds |
RunAtLoad | run job one time at load (in addition to other schedules) |
Finally, to tell launchd
that it needs to start using our Launch Agent, we run the following command:
launchctl load ~/Library/LaunchAgents/com.ackatz.converttxttomd.plist
To check that it has been added:
❯ launchctl list | grep ackatz
- 0 com.ackatz.converttxttomd
831 0 application.ackatz.seclook
[!info] seclook is a security lookup app that I wrote.. check it out!: https://seclook.app
-
for the PID tells us that our Launch Agent does not have a continuously running process associated with it, which makes sense.
0
means that it ran successfully the last time it was invoked, which is good.
launchd
Advantages over cron
Besides the fact that launchd
offers significantly more options to constrain and schedule your job [2], one interesting edge that launchd
has over cron
is related to how job scheduling is handled depending on power state:
sleep | powered off | |
---|---|---|
launchd | run @computer wakeup | run @next designated time |
cron | run @next designated time | run @next designated time |
[!info] 💡 If your machine is always powered off at the designated time, your script is not going to run.
If you were reaching for cron
in the first place, it is likely that the scheduling key you will use in your Launch Agent plist is going to either be StartCalendarInterval
or StartInterval
.
scheduling key | behavior |
---|---|
StartInterval | run job every n seconds |
StartCalendarInterval | run job at a specific time/date |
Let’s say that you decided to use StartInterval
with an interval of 30
seconds. If your machine is in a sleep state and missed 100 invocations, it will coalesce all of those missed jobs together until it wakes up, invoking it just once.
When using StartCalendarInterval
, if you missed the scheduled start time due to the machine being asleep, it will just run the job as soon as it wakes up.
It’s worth mentioning that launchd
has so many more scheduling options than just the two above. Consider the convert_txt_to_md.py
example from above. There is a scheduling option called WatchPaths
that you can point at a path/directory/file and your job will run any time that changes are detected on the path.
<key>WatchPaths</key>
<array>
<string>/path/to/directory_or_file</string>
</array>
Instead of waiting potentially an hour for the txt
to be changed to md
, WatchPaths
runs the job as needed. In my testing, it usually occurred either instantly or within a few seconds. This would have made a lot more sense to use in this scenario, so I’ve switched to it 😄
This is what it looks like in a Launch Agent 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.ackatz.converttxttomd</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/andrew.katz/convert_txt_to_md.py</string>
</array>
<key>WatchPaths</key>
<array>
<string>/Users/andrew.katz/notes/superwhisper/recordings</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
Summary
- The preferred way to run a scheduled job on macOS is using
launchd
, where you create a Launch Agent to run your job. - You can still run jobs with
cron
on macOS, but your options are way more limited (i.e., can’t run things based on triggers and less survivability of your job based on power states/missed jobs) - If you want to learn more about
launchd
and the other things you can do with it, check out this amazing site: https://launchd.info/
Sources & Further Reading
- Scheduling Timed Jobs
Explains how to write background processes that perform work on behalf of applications or serve content over the network.
https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html - A launchd Tutorial
A launchd primer covering configuration, administration and troubleshooting. Complete with examples.
https://launchd.info/