Scheduling Recurring Tasks on macOS Using Launchd
Introduction
In my setup, some workstations run macOS. Recently, I encountered a task I hadn’t faced before on macOS — scheduling recurring tasks. This note outlines the recommended approach and provides a basic introduction and step-by-step guide for achieving this, leaving the rest for you to explore further on your own.
This note is by no means a comprehensive guide and may contain inaccuracies. My aim is simply to cover the essentials and give you a nudge in the right direction for further study. I’ve also intentionally simplified some examples for clarity. If you spot an error, feel free to let me know.
Why Not Use Cron?
Of course, I’m familiar with tools like cron
for task scheduling, and
I use it regularly on Linux. But this time, I needed to solve a
similar problem on macOS. As far as I can tell, using cron
on macOS is
still possible and might even work, but it’s not the recommended
method for modern versions of the system for several reasons.
First, macOS’s primary tool for managing and executing background
tasks is Launchd, which replaced cron
as the go-to solution for
scheduling recurring tasks. Launchd supports all the functionality of
cron
and brings additional flexibility, like event-based task triggers
(for example, file changes), not just scheduled times. Apple strongly
promotes launchd
as a more native, integrated tool for these kinds of
tasks.
Second, while cron
is part of macOS’s POSIX-compatible subsystem,
Apple is gradually phasing out reliance on older system components
that don’t fully fit into its ecosystem.
Third, cron
may run into access and permission issues on modern macOS
versions, especially with macOS’s data protection systems, like System
Integrity Protection (SIP). Scheduled cron
jobs might fail to
execute or behave erratically if they lack the necessary permissions,
whereas launchd
integrates more smoothly with macOS’s access control
mechanisms.
Finally, launchd
offers more convenient monitoring and logging tools
via the launchctl
command. Cron lacks built-in centralized tools for
managing running processes, making it harder to monitor their state.
Setting Up Tasks with Launchd
So, after some reflection, I decided to give launchd
a try and set up
some of my scheduled tasks. In my case, it was a simple scheduled
backup — some files had to be backed up daily, with versioning and
rotation, while others needed backing up only once a month. Your
situation might differ, and this note isn’t about backup strategies,
so I won’t be providing the actual scripts or example workflows here.
Well, let’s dive into the sequence of steps you’ll need to follow to get things up and running.
Step 1: Create the plist File
Create a plist
file in any directory where you have write access. I
created a separate Git project in ~/jobs
and kept all my plist files
at ~/jobs/LaunchAgents
under version control. Here’s a working example
of such a file:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.serghei.orgbackup.plist</string>
<key>ProgramArguments</key>
<array>
<string>/Users/serghei/jobs/bin/backup-org.sh</string>
</array>
<key>Nice</key>
<integer>1</integer>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string><![CDATA[/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin]]></string>
</dict>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardErrorPath</key>
<string>/Users/serghei/logs/org/error.log</string>
<key>StandardOutPath</key>
<string>/Users/serghei/logs/org/output.log</string>
</dict>
</plist>
Note that the value in the Label
key must match the actual file
name. In my case, the file was named com.serghei.orgbackup.plist
. I
won’t delve into explaining every parameter or copy-pasting the
official launchd
documentation here. Instead, I’ll share some links to
further reading bellow. I’ll only point out that I had issues with the
PATH key value in the example below until I wrapped it in <![CDATA[ ... ]]>
. My script simply didn’t receive this value until I handled it
this way.
Also, as you can see in example above, I’m using StandardErrorPath
and
StandardOutPath
to define the log files. I’ve found that not worrying
about setting up a custom logging function, log file paths, or
anything unrelated to the script’s core task is extremely
convenient. My scripts are simple — I’m not building a spaceship
here. They do exactly one thing, what they’re intended for, and I
delegate the rest. When it comes to logging, I just send informational
messages to stdout
and errors to stderr
, letting the supervisor — in
this case, launchd
— handle the rest. It intercepts the output and
neatly organizes it into the respective log files. I don’t have to
worry about this in my scripts. By doing so, I stick to a principle of
minimalism in the utility code I write.
Step 2: Useful Commands for Setup
Make sure the shell script you want to run in your plist
is executable:
chmod +x bin/backup-org.sh
Here and throughout this article, I assume that all your plist files are located in a single directory, such as ~/jobs/LaunchAgents/
, and the scripts declared in these plist files are located in a neighboring directory, ~/jobs/bin/
. If this isn’t the case in your setup, make sure to adjust the paths in the examples accordingly as you follow along.
Check that everything is syntactically correct:
plutil -lint LaunchAgents/com.serghei.orgbackup.plist
This check can be performed on the local file before copying it to ~/Library/LaunchAgents
. The output should look something like this:
LaunchAgents/com.serghei.orgbackup.plist: OK
Create a symbolic link to the plist
you just made:
ln -s $(pwd)/LaunchAgents/com.serghei.orgbackup.plist ~/Library/LaunchAgents/
In my case this command was executed from the folder
where I keep all the plist
files (in the LaunchAgents
subdirectory).
Step 3: Grant Disk Access
If the script triggered by the service interacts with system
directories, like ~/Documents
or even /
, the shell (which is called by
the first line in the script, e.g., #!/bin/sh
) will need Full Disk
Access. This is set up in System Preferences -> Security & Privacy ->
Full Disk Access.
Step 4: Enable and Start the plist
To enable and start the newly created plist
:
launchctl enable gui/$(id -u)/com.serghei.orgbackup.plist
You can disable it like this:
launchctl disable gui/$(id -u)/com.serghei.orgbackup.plist
Launch the plist
:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.serghei.orgbackup.plist
You might come across guides using load
instead of bootstrap
, but this
method is outdated. While technically these commands do the same
thing, bootstrap
is the recommended modern approach.
Step 5: Monitoring the Service
View information about your service:
launchctl print gui/$(id -u)/com.serghei.orgbackup.plist
Some guides use list
, like launchctl list | grep serghei
, but print
provides more detailed output. Note that the format of the output for print
might change from release to release, so avoid relying on the format.
The output should look something like this:
gui/502/com.serghei.orgbackup.plist = {
active count = 0
path = /Users/serghei/jobs/LaunchAgents/com.serghei.orgbackup.plist
type = LaunchAgent
state = not running
program = /Users/serghei/jobs/bin/backup-org.sh
arguments = {
/Users/serghei/work/bin/backup-org.sh
}
stdout path = /Users/serghei/logs/org/output.log
stderr path = /Users/serghei/logs/org/error.log
inherited environment = {
SSH_AUTH_SOCK => /private/tmp/com.apple.launchd.I9kcWZktTQ/Listeners
}
default environment = {
PATH => /usr/bin:/bin:/usr/sbin:/sbin
}
environment = {
PATH => /usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin
XPC_SERVICE_NAME => com.serghei.orgbackup.plist
}
domain = gui/502 [100029]
asid = 100029
minimum runtime = 10
exit timeout = 5
nice = 1
runs = 0
last exit code = (never exited)
event triggers = {
com.serghei.orgbackup.plist.268435472 => {
keepalive = 0
service = com.serghei.orgbackup.plist
stream = com.apple.launchd.calendarinterval
monitor = com.apple.UserEventAgent-Aqua
descriptor = {
"Minute" => 0
"Hour" => 9
}
}
}
event channels = {
"com.apple.launchd.calendarinterval" = {
port = 0x0
active = 0
managed = 1
reset = 0
hide = 0
watching = 1
}
}
spawn type = daemon (3)
jetsam priority = 40
jetsam memory limit (active) = (unlimited)
jetsam memory limit (inactive) = (unlimited)
jetsamproperties category = daemon
jetsam thread limit = 32
cpumon = default
probabilistic guard malloc policy = {
activation rate = 1/1000
sample rate = 1/0
}
properties = inferred program
}
You can use the print
command to view lots of things, like all your
services:
launchctl print gui/$(id -u)
For convenience, I wrote a small script to output information about
all my services. This script mimics the legacy launchctl bslist
command, which is no longer available. It uses launchctl print
to
extract and display a list of active or on-demand services registered
with the system’s bootstrap server.
- If the script is run as root, it targets the system-wide domain.
- If run as a non-privileged user, it targets the per-user domain.
- The output is filtered to only show relevant service information in a concise format.
#!/bin/sh
#
# ~/.local/bin/bslist
#
if [ $(id -u) -eq 0 ]; then
domain=system
else
domain="user/$(id -u)"
fi
launchctl print $domain |\
sed -e '1,/endpoints = {/d' -e '/}/,$d' -e 's/.* \([A|D]\)\( *\)\(.*\)/\1 \3/';
Typical script output is:
$ bslist
A com.apple.finder.ServiceProvider
D com.apple.udb.system-push
D com.apple.systemprofiler
A com.apple.systemuiserver.ServiceProvider
A com.apple.dock.server
[...]
where:
- the first column is the bootstrap service state (A for “Active” and D “On-demand”)
- the second column is the name of the bootstrap service
Step 6: Stopping and Disabling the Service
To stop the service use the somman as follows:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.serghei.orgbackup.plist
To disable the service:
launchctl disable gui/$(id -u)/com.serghei.orgbackup.plist
Launchd vs. Systemd
Both launchd
on macOS and systemd
on Linux serve as powerful
orchestrators for managing services and scheduled tasks. While they
share the same mission of replacing older, less flexible systems like
cron
, each brings its own unique flavor to the table.
In terms of similarities, both systems offer event-driven scheduling,
allowing tasks to be triggered not just by time but by system events
such as file changes or network activity. Additionally, both provide
tools for monitoring and managing these tasks — launchctl
for launchd
and systemctl
for systemd
— offering users fine control over what runs
and when.
However, the differences are equally striking. Systemd is deeply rooted in the Linux ecosystem, offering advanced capabilities like cgroup management and process isolation. Launchd, while integrated with macOS, focuses on simplicity and system integration, with its leaner plist configuration files. Where systemd might be considered more feature-rich, it can also feel more complex, whereas launchd aims for elegance and streamlined functionality.
How I Use It
In my case, I’ve set up a modest suite of about a dozen tasks. These
range from scheduled backups to generating a weekly report by calling
Emacs through emacsclient
with the function (org-store-agenda-views)
,
syncing files between workstations and cloud, sending automated email
notifications with required reports, regularly fetching RSS feeds, and
more. Your specific use case might differ, but from my experience,
launchd
is a robust and reliable tool for everyday automation.
Give it a shot — try it out, and I doubt you’ll be disappointed.
Further Reading and Documentation
The following man pages are highly useful:
man 1 launchctl
: Interfaces withlaunchd
.man 5 launchd.plist
: System-wide and per-user daemon/agent configuration files.