Scheduling Recurring Tasks on macOS Using Launchd

8 minute read

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 with launchd.
  • man 5 launchd.plist: System-wide and per-user daemon/agent configuration files.

References