launchctl
Managing services, daemons, and scheduled jobs on macOS with launchd and launchctl.
launchctl
Managing services, daemons, and scheduled jobs on macOS with launchd and launchctl.
Overview
launchd is the service management framework on macOS and the first userspace process (PID 1). It bootstraps the system, supervises daemons and agents, restarts crashed jobs, and handles on-demand activation via sockets, paths, and timers. It is the rough analogue of systemd on Linux, though the model and vocabulary differ.
Jobs are declared in property-list (plist) files rather than INI-style units. Each job has a unique reverse-DNS Label and is loaded into a domain — the system domain, or a per-user GUI/Aqua session. launchctl is the command-line client that talks to launchd.
Two job kinds exist, distinguished entirely by where the plist lives:
- Daemons run once per machine in the system domain, as root (or a named user), with no access to a login session or the window server. Background services.
- Agents run per-user inside a GUI/Aqua session, with access to the user's environment and (optionally) the display. Login items, menu-bar helpers, per-user schedulers.
graph TB
A["launchd (PID 1)"] --> S["system domain"]
A --> G["gui/<uid> domain (per logged-in user)"]
A --> U["user/<uid> domain"]
S --> D1["LaunchDaemon: com.example.sync"]
S --> D2["LaunchDaemon: org.postgresql.postgres"]
G --> A1["LaunchAgent: com.example.notifier"]
G --> A2["LaunchAgent: com.example.backup"]
Domains and Service Targets
Since OS X 10.10 (Yosemite), launchctl uses domain targets and service targets to address jobs unambiguously. This is the modern syntax and the primary form throughout this sheet.
Key Concepts
A domain target identifies where a job lives:
| Domain target | Meaning |
|---|---|
system/ |
The system domain — LaunchDaemons, root context |
gui/<uid>/ |
The GUI (Aqua) session for user <uid> — LaunchAgents with display access |
user/<uid>/ |
The user domain for <uid> — non-GUI per-user context |
session/<asid>/ |
A security audit session domain |
pid/<pid>/ |
The domain of a specific running process |
A service target is <domain-target>/<service-name>, where the service name is the job's Label. For example:
system/com.example.syncgui/501/com.example.notifier
Get your own uid with id -u (commonly 501 for the first user). $UID works in most shells.
# Address a daemon in the system domain
launchctl print system/com.example.sync
# Address an agent in the current user's GUI session
launchctl print "gui/$(id -u)/com.example.notifier"
Modern Verbs
| Verb | Purpose |
|---|---|
bootstrap |
Load a job into a domain (replaces load) |
bootout |
Unload a job / tear down a domain (replaces unload) |
enable |
Mark a service enabled (persists across reboots) |
disable |
Mark a service disabled (persists across reboots) |
kickstart |
Start (or with -k, restart) a service immediately |
kill |
Send a signal to a running service |
print |
Inspect a domain or service in detail |
print-disabled |
Print a domain's enable/disable override records (both states) |
blame |
Explain why a service is currently running |
list |
List loaded jobs (legacy-flavoured but still current) |
dumpstate |
Dump the complete state of every domain (large) |
procinfo |
Show launchd's view of a running PID |
asuser |
Run a command in another user's bootstrap context |
bsexec |
Run a command in another process's bootstrap context |
setenv / getenv |
Get and set the domain's session environment |
limit |
Show or set resource limits for the domain |
config |
Write persistent domain configuration (needs a reboot) |
reboot |
Restart userspace, log out, or reboot the machine |
Legacy Syntax (deprecated but still seen)
The old verbs — load, unload, start, stop, list — operate on plist paths or bare labels and implicitly target the caller's domain. Apple deprecated them in favour of the domain-aware form, but they remain everywhere in older docs, blog posts, and install scripts, and still function.
| Legacy command | Modern equivalent |
|---|---|
launchctl load <plist> |
launchctl bootstrap <domain> <plist> |
launchctl load -w <plist> |
launchctl bootstrap <domain> <plist> + launchctl enable <target> |
launchctl unload <plist> |
launchctl bootout <domain> <plist> |
launchctl unload -w <plist> |
launchctl disable <target> + launchctl bootout ... |
launchctl start <label> |
launchctl kickstart <domain>/<label> |
launchctl stop <label> |
launchctl kill TERM <domain>/<label> |
launchctl list |
launchctl print <domain> |
The legacy
-wflag conflated enabling (persistent) with loading (this boot). The modern verbs separate the two:bootstrap/bootoutaffect the current boot,enable/disablepersist.
Plist Locations
Where a plist lives determines whether it is an agent or a daemon, and in which session it runs.
| Path | Kind | Session | Owner |
|---|---|---|---|
~/Library/LaunchAgents |
Agent | This user, on their login | The user |
/Library/LaunchAgents |
Agent | Every user, on their login | Admin |
/Library/LaunchDaemons |
Daemon | System, at boot | Admin (root) |
/System/Library/LaunchAgents |
Agent | Apple-supplied | System (SIP-protected) |
/System/Library/LaunchDaemons |
Daemon | Apple-supplied | System (SIP-protected) |
flowchart TD
Q{"Needs a user GUI session?"} -->|Yes| Agent["LaunchAgent"]
Q -->|No, runs machine-wide| Daemon["LaunchDaemon"]
Agent --> AP1["~/Library/LaunchAgents (one user)"]
Agent --> AP2["/Library/LaunchAgents (all users)"]
Daemon --> DP["/Library/LaunchDaemons (root)"]
Never place or edit your own jobs under
/System/Library— those directories are owned by macOS and protected by System Integrity Protection (SIP). Put admin-installed jobs in/Library/....
Anatomy of a plist
Job definitions are XML property lists (a binary1 form also exists; see plist Tooling). The keys below cover the vast majority of real jobs.
Core Keys
| Key | Type | Purpose |
|---|---|---|
Label |
string | Unique job identifier (reverse-DNS); should match the filename |
ProgramArguments |
array | argv — executable plus arguments (each a separate element) |
Program |
string | Single executable path (use instead of ProgramArguments for argv[0]-only) |
RunAtLoad |
bool | Start the job as soon as it is loaded |
KeepAlive |
bool / dict | Keep the job running; restart on exit (see below) |
StartInterval |
integer | Run every N seconds |
StartCalendarInterval |
dict / array | Cron-like scheduling (see below) |
WatchPaths |
array | Start the job when any listed path changes |
QueueDirectories |
array | Start the job while any listed directory is non-empty |
StandardOutPath |
string | Redirect stdout to a file |
StandardErrorPath |
string | Redirect stderr to a file |
EnvironmentVariables |
dict | Environment for the job |
WorkingDirectory |
string | chdir before exec |
ThrottleInterval |
integer | Minimum seconds between respawns (default 10) |
ProcessType |
string | Scheduling class: Background, Standard, Adaptive, Interactive |
UserName / GroupName |
string | Run the job as this user/group (daemons only) |
RunAtLoadvs on-demand: with neitherRunAtLoadnorKeepAlive, the job is on-demand —launchdonly launches it when a trigger fires (socket connection,WatchPathschange,StartCalendarInterval,StartInterval, or an explicitkickstart).
KeepAlive Dictionary Forms
KeepAlive as a boolean (true) restarts the job unconditionally. As a dictionary it restarts conditionally:
| Sub-key | Type | Behaviour |
|---|---|---|
SuccessfulExit |
bool | true: restart only after exit 0; false: restart only after non-zero |
Crashed |
bool | true: restart only if the job crashed (killed by signal) |
NetworkState |
bool | true: keep alive while the network is up |
PathState |
dict | Per-path bool: keep alive while a path does (true) / does not (false) exist |
OtherJobEnabled |
dict | Per-label bool: keep alive while another job is loaded |
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/> <!-- restart only on failure (non-zero exit) -->
<key>PathState</key>
<dict>
<key>/etc/myapp/enabled</key>
<true/> <!-- run only while this flag file exists -->
</dict>
</dict>
Further Keys
Beyond the core set, these cover process lifecycle, resource limits, and session placement. Most jobs need none of them; each solves a specific problem.
| Key | Type | Purpose |
|---|---|---|
BundleProgram |
string | Executable path relative to the containing bundle — use for agents shipped inside a .app |
LaunchOnlyOnce |
bool | Run at most once per boot; launchd will not respawn it afterwards |
ExitTimeOut |
integer | Seconds between SIGTERM and SIGKILL on stop (default is system-defined — 5 on macOS 26; check launchctl print) |
AbandonProcessGroup |
bool | Do not kill the job's surviving children when the job itself exits |
Umask |
integer / string | umask(2) value — an integer is decimal; a string is octal (see below) |
RootDirectory |
string | chroot(2) here before exec |
InitGroups |
bool | Call initgroups(3) so the job gets its user's supplementary groups (default true) |
Nice |
integer | Scheduling niceness, -20–20 |
LowPriorityIO |
bool | Mark the job's disk I/O as throttleable |
SoftResourceLimits |
dict | Per-job soft setrlimit(2) values |
HardResourceLimits |
dict | Per-job hard setrlimit(2) values |
LimitLoadToSessionType |
string / array | Restrict which session types the job loads into (see below) |
SessionCreate |
bool | Spawn the job in its own security audit session rather than launchd's |
EnableTransactions |
bool | The job uses xpc_transaction_begin/end; launchd only kills it when idle |
EnablePressuredExit |
bool | The job may be asked to exit under memory pressure |
StartOnMount |
bool | Start the job every time a filesystem is mounted (see On-Demand Activation) |
Sockets |
dict | Socket-activated launch (see On-Demand Activation) |
MachServices |
dict | Register Mach/XPC service names (see On-Demand Activation) |
Disabled |
bool | Legacy in-plist disable flag — usually ignored, see below |
SoftResourceLimits / HardResourceLimits accept NumberOfFiles, NumberOfProcesses, Core, CPU, Data, FileSize, MemoryLock, ResidentSetSize, and Stack.
<key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>16384</integer>
</dict>
<key>ExitTimeOut</key>
<integer>60</integer> <!-- allow a slow drain before SIGKILL -->
<key>Umask</key>
<string>022</string> <!-- string => octal; <integer>18</integer> is the same value -->
An
Umaskinteger is decimal. Writing<integer>22</integer>and meaning022gets you octal026, so a file created from mode0666lands at0640rather than0644— silently unreadable by everyone outside the group, which surfaces much later as a permission error in something else. Convert first:echo $(( 8#022 ))(bash and zsh) orpython3 -c 'print(0o022)'— both give18.Better:
Umaskalso takes a string, and a string is octal.launchd.plist(5)types the key<integer or string>and parses a string withstrtoul(3), so a leading0selects base 8.<string>022</string>means exactly what it looks like, needs no conversion, and cannot be misread by the next person to open the file — prefer it for anything hand-written.
SockPathModehas no such escape hatch. It is<integer>only, and the man page's "Known bug: Property lists don't support octal, so please convert the value to decimal" belongs to that key specifically — not toUmask.
Disabledin the plist is not the switch you want.launchdkeeps its own override database, andlaunchctl enable/disablewrite there. Once a label has an override recorded, that override wins and the in-plistDisabledkey is ignored. Use the verbs; treat the key as legacy.
LimitLoadToSessionType
Agents default to session type Aqua — the logged-in GUI session. This key changes that, and it is what distinguishes a job that will load into gui/<uid> from one that will load into user/<uid>.
| Value | Session |
|---|---|
Aqua |
Normal logged-in GUI session (the default for agents) |
Background |
Per-user, no GUI — the user/<uid> domain; loads before login |
LoginWindow |
The login window itself, before any user logs in |
System |
The system domain |
StandardIO |
Non-GUI login (e.g. an ssh session) |
<!-- Load for the user even when nobody is logged in at the console -->
<key>LimitLoadToSessionType</key>
<string>Background</string>
An agent with
LimitLoadToSessionTypeset toBackgroundwill refuse to bootstrap intogui/<uid>— and vice versa.Bootstrap failed: 5immediately after adding this key almost always means the domain and the session type disagree.
Full Example: LaunchDaemon
A root-owned background service that runs at boot, restarts on failure, and logs to /var/log.
<?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.example.sync</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/sync-agent</string>
<string>--config</string>
<string>/usr/local/etc/sync.toml</string>
</array>
<key>UserName</key>
<string>_sync</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>30</integer>
<key>ProcessType</key>
<string>Background</string>
<key>WorkingDirectory</key>
<string>/usr/local/var/sync</string>
<key>EnvironmentVariables</key>
<dict>
<key>SYNC_ENV</key>
<string>production</string>
</dict>
<key>StandardOutPath</key>
<string>/var/log/sync-agent.log</string>
<key>StandardErrorPath</key>
<string>/var/log/sync-agent.err</string>
</dict>
</plist>
Save as /Library/LaunchDaemons/com.example.sync.plist.
Full Example: LaunchAgent
A per-user agent that runs a backup on a schedule and when a watched directory changes.
<?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.example.backup</string>
<key>ProgramArguments</key>
<array>
<string>/Users/mike/bin/backup.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>2</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
<key>WatchPaths</key>
<array>
<string>/Users/mike/Documents/Important</string>
</array>
<key>StandardOutPath</key>
<string>/Users/mike/Library/Logs/backup.log</string>
<key>StandardErrorPath</key>
<string>/Users/mike/Library/Logs/backup.log</string>
</dict>
</plist>
Save as ~/Library/LaunchAgents/com.example.backup.plist.
StartCalendarInterval (the cron analogue)
StartCalendarInterval schedules a job at wall-clock times. Each entry is a dict with any of Minute, Hour, Day (of month), Weekday (0–7, Sunday is 0 and 7), and Month. Omitted fields act as a wildcard (every value). There are no cron-style ranges, lists, or steps (*/5, 1-5, 1,15) within a single field — to express those you supply an array of dicts, one per concrete time.
<!-- Daily at 02:30 -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>2</integer>
<key>Minute</key><integer>30</integer>
</dict>
<!-- Every hour, on the hour (Minute fixed, Hour wildcard) -->
<key>StartCalendarInterval</key>
<dict>
<key>Minute</key><integer>0</integer>
</dict>
<!-- Weekdays (Mon–Fri) at 09:00 — one dict per day -->
<key>StartCalendarInterval</key>
<array>
<dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>2</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>3</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>4</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Weekday</key><integer>5</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
</array>
<!-- Every 15 minutes — one dict per quarter-hour (no step syntax) -->
<key>StartCalendarInterval</key>
<array>
<dict><key>Minute</key><integer>0</integer></dict>
<dict><key>Minute</key><integer>15</integer></dict>
<dict><key>Minute</key><integer>30</integer></dict>
<dict><key>Minute</key><integer>45</integer></dict>
</array>
For simple fixed periods,
StartInterval(run every N seconds) is far terser than enumerating times. UseStartCalendarIntervalonly when you need specific wall-clock alignment.
On-Demand Activation
launchd's defining feature is that it can hold a resource open on a job's behalf and start the job only when something actually arrives. The job stays unloaded (costing nothing) until a client connects, a file changes, or a directory fills.
flowchart LR
C["Client"] --> L["launchd holds the listener"]
L -->|"first connection"| J["job launched"]
L -->|"fd handed over"| J
J -->|"idle, no transactions"| X["exits; launchd keeps listening"]
X -.->|"next connection"| J
| Trigger | Key | Fires when |
|---|---|---|
| Socket | Sockets |
A client connects to a socket launchd is listening on |
| Mach / XPC | MachServices |
A client looks up the registered service name |
| Path change | WatchPaths |
Any listed file or directory is modified |
| Queue | QueueDirectories |
A listed directory becomes non-empty |
| Timer | StartInterval, StartCalendarInterval |
The interval elapses / the wall-clock time arrives |
| Mount | StartOnMount |
Any filesystem is mounted — any, not a nominated one |
Socket Activation
launchd creates, binds, and listens on the socket itself, then passes the ready file descriptor to the job. Because launchd owns the listener, connections that arrive while the job is down are queued rather than refused — there is no startup race.
<key>Sockets</key>
<dict>
<key>Listeners</key> <!-- arbitrary name; the job asks for it by this name -->
<dict>
<key>SockServiceName</key>
<string>8080</string> <!-- port number, or a name from /etc/services -->
<key>SockType</key>
<string>stream</string> <!-- stream | dgram | seqpacket -->
<key>SockFamily</key>
<string>IPv4</string> <!-- IPv4 | IPv6 | IPv4v6 -->
</dict>
</dict>
IPv4v6 asks for a single socket serving both families. For a UNIX-domain socket you do not set SockFamily at all — SockPathName implies it (plus SockPathMode, which is decimal):
<key>Sockets</key>
<dict>
<key>Listeners</key>
<dict>
<key>SockPathName</key>
<string>/var/run/com.example.sync.sock</string>
<key>SockPathMode</key>
<integer>438</integer> <!-- DECIMAL 438 == octal 0666 -->
</dict>
</dict>
The job retrieves the descriptors with launch_activate_socket(3), keyed by the dictionary name:
#include <launch.h>
int *fds = NULL;
size_t count = 0;
// "Listeners" must match the key in the Sockets dict
if (launch_activate_socket("Listeners", &fds, &count) == 0) {
for (size_t i = 0; i < count; i++) {
// fds[i] is already bound and listening — just accept() on it
accept_loop(fds[i]);
}
free(fds); // caller owns the array
}
launch_activate_socketis the current API. The olderlaunch_msg/launch_data_tcheckin dance is deprecated and does not work for jobs bootstrapped into modern domains — if you find it in a blog post, the post predates 10.10.
RunAtLoadon a socket-activated job is usually a mistake, but not a fatal one: it launches the job once at load time —launchd.plist(5)calls it "launched once at the time the job is loaded" — which defeats the point of waiting for demand. It does not break the descriptor hand-off;launch_activate_socketstill returns the listeners on that first launch, and demand can start the job again after it exits. Leave it out unless you actually want an unconditional launch at load.
Mach Services (XPC)
MachServices registers names in the domain's bootstrap namespace. A client connecting to the name with xpc_connection_create_mach_service causes launchd to launch the job.
<key>MachServices</key>
<dict>
<key>com.example.helper</key>
<true/>
</dict>
| Sub-key form | Meaning |
|---|---|
<true/> |
Register the name; launchd launches the job on lookup |
HideUntilCheckIn |
Hide the port until the job checks in — lookups block rather than failing during startup |
ResetAtClose |
Recreate the port when the job's connection to it closes |
# Confirm the name is registered in the domain
launchctl print "gui/$(id -u)/com.example.helper" | grep -A5 -i 'endpoints\|mach'
The service name must be in the same domain as the client. A daemon's
MachServicesname is not visible to a GUI agent's lookup unless the daemon registers in the system domain and the client has the entitlement to reach it.
Path and Queue Triggers
<!-- Run whenever any of these paths is modified -->
<key>WatchPaths</key>
<array>
<string>/usr/local/etc/sync.toml</string>
<string>/Users/mike/Documents/Important</string>
</array>
<!-- Run while this directory has anything in it; re-run until it is drained -->
<key>QueueDirectories</key>
<array>
<string>/usr/local/var/spool/sync</string>
</array>
Apple discourages
WatchPathsoutright.launchd.plist(5): "Use of this key is highly discouraged, as filesystem event monitoring is highly race-prone, and it is entirely possible for modifications to be missed." Treat a fire as a hint that something changed, never as a guarantee you saw every change — if you need reliable delivery, useQueueDirectories(a file left in a spool directory cannot be missed) or poll onStartInterval.
Beyond that:
WatchPathswatches the path, not its contents recursively — a change deep inside a watched directory tree does not necessarily fire. It also fires on any modification including the job's own writes, which is a well-worn way to build an infinite loop. Have the job write elsewhere, or debounce.
QueueDirectorieskeeps relaunching the job until the directory is empty, so the job must actually consume and remove its input, or it will spin.
Managing Jobs End-to-End
Bootstrap a Daemon
# 1. Place the plist (root-owned, 644 — see Common Issues)
sudo cp com.example.sync.plist /Library/LaunchDaemons/
sudo chown root:wheel /Library/LaunchDaemons/com.example.sync.plist
sudo chmod 644 /Library/LaunchDaemons/com.example.sync.plist
# 2. Load it into the system domain
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.sync.plist
# 3. Confirm it loaded
sudo launchctl print system/com.example.sync
Bootstrap an Agent
# Agents load into the current user's GUI session — no sudo
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.example.backup.plist
launchctl print "gui/$(id -u)/com.example.backup"
Start / Restart
# Start now (idempotent if already running)
sudo launchctl kickstart system/com.example.sync
# Restart: -k kills the running instance first
sudo launchctl kickstart -k system/com.example.sync
# Start and print the resulting PID
sudo launchctl kickstart -kp system/com.example.sync
Stop / Signal
# Graceful stop (SIGTERM). If KeepAlive is set, launchd restarts it.
sudo launchctl kill TERM system/com.example.sync
# Force kill
sudo launchctl kill KILL system/com.example.sync
Unload
# Remove from the domain for this boot
sudo launchctl bootout system/com.example.sync
# Equivalent by path
sudo launchctl bootout system /Library/LaunchDaemons/com.example.sync.plist
Enable / Disable (persist across reboots)
bootout only affects the current boot — a job whose plist sits in /Library/LaunchDaemons reloads at the next boot. To stop it permanently, disable it (recorded in launchd's on-disk override database).
# Permanently disable (survives reboot)
sudo launchctl disable system/com.example.sync
# Re-enable
sudo launchctl enable system/com.example.sync
# Override records for this domain
sudo launchctl print-disabled system
print-disabledis misnamed. It dumps the whole override database, not just the disabled half — every entry comes back as"<label>" => disabledor"<label>" => enabled. Grepping for a label therefore tells you nothing on its own; read the value.launchctl enabledoes not delete a record either, it flips it to=> enabled, and there is no verb that removes one.
disablerecords the override but does not stop a currently-running job; follow withbootout. Converselyenabledoes not start the job; follow withbootstrap/kickstart(or reboot).
Inspect
# Full detail: state, PID, last exit, KeepAlive, pending events, env
sudo launchctl print system/com.example.sync
# Why is this job running right now?
sudo launchctl blame system/com.example.sync
# List loaded jobs (label, PID, last exit status)
launchctl list # current user's domain
sudo launchctl list | grep example # find a label
# Dump a whole domain
sudo launchctl print system | less
launchctl list columns are PID, last exit status, Label. A - PID means loaded-but-not-running; a non-zero exit status is a quick failure signal.
The
sudoonblame, andprint-disabledis habit, not a requirement — read-only inspection of the system domain works unprivileged. It islistthat genuinely differs: with no domain target of its own, barelaunchctl listshows your domain andsudo launchctl listshows the system's. Mutation (bootstrap,bootout,kickstart,enable) does need root, and fails withOperation not permittedwithout it.
Deeper Inspection
When print and blame are not enough.
# Dump launchd's ENTIRE state — every domain, every job, every endpoint.
# Large (several MB on an idle desktop, more under root); redirect and grep
# rather than paging blind.
sudo launchctl dumpstate > /tmp/launchd-state.txt
grep -n -A20 'com.example.sync' /tmp/launchd-state.txt
# What does launchd know about a running PID? (domain, job label, sandbox)
sudo launchctl procinfo 12345
# Which launchd instance manages the current context?
launchctl managername # e.g. "Aqua"
launchctl manageruid # the uid, or 0 in the system domain
launchctl managerpid # PID of the managing launchd
Running Commands in Another Context
Scripts that run from a daemon (or over ssh) sit in the system domain and cannot reach a user's GUI session — osascript, notifications, and open all fail. asuser re-enters the user's bootstrap context.
# Run inside user 501's GUI bootstrap context, as root
sudo launchctl asuser 501 launchctl print "gui/501"
# The usual idiom: right context AND right user
uid=$(id -u mike)
sudo launchctl asuser "$uid" sudo -u mike osascript -e 'display notification "done"'
# Run in the bootstrap context of another process — PID 1 (launchd) is the
# system context; use loginwindow's PID for a user's GUI context
sudo launchctl bsexec 1 /usr/local/bin/some-tool
asuserchanges the bootstrap context, not the uid — the command still runs as root unless you alsosudo -u. Getting one without the other is the single most common cause of "works in my terminal, fails from the daemon".
Restarting launchd Itself
# Restart userspace only — every daemon and agent, without a hardware reboot.
# Much faster than a reboot; still logs everyone out.
sudo launchctl reboot userspace
# Other targets: apps | logout | userspace | halt | system
sudo launchctl reboot logout
launchctl reboot userspaceis a genuine reboot of everything above the kernel. It is the honest way to test "does my daemon come up cleanly at boot?" without waiting for a full restart — but it is not a no-op, so do not run it on a machine doing work.
Clean Uninstall
Removing the plist alone is not enough — the job stays loaded for this boot, and any disable override outlives the file.
LABEL=com.example.sync
DOMAIN=system # or "gui/$(id -u)" for an agent
# 1. Stop and unload for this boot
sudo launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null
# 2. Clear any disable override, or a future reinstall silently never starts
sudo launchctl enable "$DOMAIN/$LABEL" 2>/dev/null
# 3. Remove the job definition
sudo rm -f "/Library/LaunchDaemons/$LABEL.plist"
# 4. Confirm it is gone from the domain, and not left disabled.
# print-disabled reports both states, so read the value rather than the
# hit: "=> enabled" is fine, "=> disabled" is not.
sudo launchctl print "$DOMAIN/$LABEL" # expect "Could not find service"
sudo launchctl print-disabled "$DOMAIN" | grep "$LABEL"
# 5. Tidy up whatever the job left behind
sudo rm -f /var/log/sync-agent.log /var/log/sync-agent.err
Step 2 is the one everyone skips. The override database is keyed by label, not by file, so a label you once
disabled stays disabled after the plist is deleted and reinstalled — the new job bootstraps without error and then never runs.launchctl print-disabled <domain>is how you catch it.
Environment, Limits, and launchd Config
Session Environment Variables
launchd holds an environment that it hands to jobs it spawns — including GUI applications, which is why an app launched from the Dock does not see your shell's PATH.
# Set a variable for jobs launched from here on, in this domain
launchctl setenv MY_API_HOST api.internal.example
# Read it back
launchctl getenv MY_API_HOST
# Remove it
launchctl unsetenv MY_API_HOST
setenvaffects subsequently launched jobs only — already-running processes keep the old environment. And it does not survive a reboot. To make it stick, set it from aRunAtLoadagent, or better, put the value in the job's ownEnvironmentVariablesdict where it is declarative and visible in the plist.
Resource Limits
# Show all current limits for the domain
launchctl limit
# Show one
launchctl limit maxfiles
# => maxfiles 256 unlimited
# Set soft and hard (root; current boot only)
sudo launchctl limit maxfiles 65536 65536
The hard limit cannot usefully exceed
kern.maxfilesperproc— check it first withsysctl kern.maxfiles kern.maxfilesperproc, and pick a hard limit at or below it. (On a stock macOS 26 desktop that is92160, so the frequently-copied... 200000is above the ceiling.) For a single service, prefer per-jobSoftResourceLimitsin the plist over raising the global limit: it is scoped, declarative, and survives reboot without a helper daemon.
Persistent launchd Configuration
launchctl config writes to launchd's on-disk configuration and is the supported way to set a domain-wide umask or PATH.
# Default PATH for the user domain
sudo launchctl config user path /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
# umask for the system domain — OCTAL here, unlike the plist key
sudo launchctl config system umask 022
configtakes octal; the plist key takes decimal.launchctl config ... umaskparses its value withstrtoul(3)base 8, so022means what it looks like. TheUmaskplist key is decimal, because property lists cannot express octal. Same word, two bases, a few sections apart — check which one you are editing.
launchctl configrequires a reboot to take effect — there is no way to apply it live. It also requires root, and it persists, which makes it easy to forget you set it.
There is no read form: the synopsis is
config system | user parameter valuewith the value mandatory, solaunchctl config system umaskon its own is just a usage error. To see what is set, read the filelaunchdpersists it to — an implementation detail rather than a supported interface, so treat a miss as inconclusive:
sudo plutil -p /var/db/com.apple.xpc.launchd/config/system.plist
sudo plutil -p /var/db/com.apple.xpc.launchd/config/user.plist
plist Tooling
plutil is the canonical tool for validating and converting property lists. Edit XML plists with any text editor; never hand-edit the binary1 form.
# Validate syntax (run this before every bootstrap)
plutil -lint com.example.sync.plist
# => com.example.sync.plist: OK
# Convert binary -> XML for editing
plutil -convert xml1 SomeBinary.plist
# Convert back to binary (rarely needed for launchd; XML is fine)
plutil -convert binary1 com.example.sync.plist
# Convert to JSON for inspection / scripting (read-only view)
plutil -convert json -o - com.example.sync.plist
# Read a single key
plutil -extract Label raw com.example.sync.plist
Avoid the
defaultscommand for launchd plists.defaultsrewrites files in macOS's preferred (binary) format and strips comments, which mangles a hand-authored XML job. Useplutilor a text editor.defaultsis for the preferences domain, not job definitions.
Login Items and SMAppService (macOS 13+)
Ventura (macOS 13) added Background Task Management (BTM): every third-party agent and daemon on the machine is enumerated in System Settings → General → Login Items & Extensions, where the user can switch it off. A job the user has toggled off will not run, no matter how correctly it is installed — and launchctl bootstrap still returns success.
flowchart TD
P["plist in LaunchAgents / LaunchDaemons"] --> B["BTM registers a background item"]
B --> UI["Login Items and Extensions"]
UI -->|"user leaves it on"| R["job runs normally"]
UI -->|"user switches it off"| S["job never launches; bootstrap still succeeds"]
P -.->|"AssociatedBundleIdentifiers"| N["shown under the app name, not the raw label"]
This is the first thing to check when a job that used to work stops running after an OS upgrade.
AssociatedBundleIdentifiers
Without this key, your job appears in the Login Items list under whatever name BTM can scrape — often the developer's signing identity, or the bare label. Users disable things they do not recognise.
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.example.SyncApp</string>
</array>
Add it to the agent or daemon plist and the item is grouped under that app's name and icon. It is purely cosmetic to launchd, and decisive for whether the user leaves your job enabled.
Registering from an App Bundle
For anything shipped inside a .app, do not write to ~/Library/LaunchAgents by hand. SMAppService (the ServiceManagement framework, macOS 13+) registers a plist that lives inside the bundle, so install and uninstall follow the app.
| Bundle location | Factory method | Domain |
|---|---|---|
Contents/Library/LaunchAgents/<name>.plist |
SMAppService.agent(plistName:) |
Per-user agent |
Contents/Library/LaunchDaemons/<name>.plist |
SMAppService.daemon(plistName:) |
System daemon (needs admin approval) |
Contents/Library/LoginItems/<helper>.app |
SMAppService.loginItem(identifier:) |
Helper app at login |
| The app itself | SMAppService.mainApp |
Relaunch the app at login |
import ServiceManagement
let service = SMAppService.agent(plistName: "com.example.notifier.plist")
do {
try service.register()
} catch {
// Registration can fail because the user has already denied it
print("register failed: \(error)")
}
switch service.status {
case .enabled: break // registered and permitted to run
// (an on-demand agent may be idle)
case .requiresApproval: SMAppService.openSystemSettingsLoginItems()
case .notRegistered: break // never registered, or unregistered
case .notFound: break // plist missing from the bundle
@unknown default: break
}
try? service.unregister() // clean removal
SMAppServicesupersedes bothSMLoginItemSetEnabled(login-item helpers) andSMJobBless(privileged helper installation), each deprecated in Ventura. New code should not use either.
.requiresApprovalis not an error — it is the normal state after registering when the user has not yet said yes.openSystemSettingsLoginItems()takes them to the right pane; there is no API to grant it on their behalf, by design.
Inspecting the BTM Database
# Dump the Background Task Management database. No sudo: it asks
# Authorization Services for the admin right and pops a GUI dialog.
sfltool dumpbtm
# Which items has the user actually switched off?
sfltool dumpbtm | grep -B6 'Disposition:.*\[disabled'
# The daemon that owns it. It is MachServices-activated with RunAtLoad=false,
# so "not running" is its normal idle state — pgrep finds nothing on a
# perfectly healthy machine. Ask launchd, not the process table.
launchctl print system/com.apple.backgroundtaskmanagementd | grep -E 'state =|program ='
sfltool dumpbtmruns on a normal machine — SIP does not have to be disabled (verified on macOS 26.6.2 with SIP enabled). What it does need is thesystem.privilege.adminright, which it requests through Authorization Services: a GUI dialog appears, and if nobody is there to approve it the command dies witherrAuthorizationCanceled: The authorization was cancelled by the user. That makes it useless from a script, a daemon, or ansshsession with no console — which is probably how it acquired its reputation for being blocked.The output is grouped by UID (
-2,0, and each real user) and gives each item aTypeand aDisposition.Dispositionis the answer to "did the user switch this off":[enabled, allowed, notified]versus[disabled, allowed, not notified].Typetells you how the item was installed — a plain plist dropped in aLaunch*directory shows up aslegacy agentorlegacy daemon, while anSMAppServiceregistration appears asagent,app, orlogin item.Reading is all you get. There is still no supported CLI to re-enable an item a user has switched off — that is deliberate. (
sfltooldoes haveresetbtm, but it resets the whole database rather than one item.)
Homebrew Services
brew services is a thin wrapper over launchctl: it generates a plist from the formula's service block, drops it in a Launch* directory, and bootstraps it. Knowing which directory it chose is most of the debugging.
brew services list # name, status, user, plist path
brew services start postgresql@16 # generate plist + bootstrap
brew services stop postgresql@16 # bootout + remove plist
brew services restart postgresql@16
brew services info postgresql@16 # detail for one formula
brew services run postgresql@16 # run once, do NOT install a plist
brew services cleanup # prune plists for uninstalled formulae
| Invocation | Plist written to | Domain | Runs as |
|---|---|---|---|
brew services start <f> |
~/Library/LaunchAgents/homebrew.mxcl.<f>.plist |
gui/<uid> |
You |
sudo brew services start <f> |
/Library/LaunchDaemons/homebrew.mxcl.<f>.plist |
system |
root |
brew services start <f> over SSH |
~/Library/LaunchAgents/homebrew.mxcl.<f>.plist |
user/<uid> |
You |
Homebrew silently drops to
user/<uid>when it cannot reach your GUI session — over SSH without owning/dev/console, underHOMEBREW_SUDO_USER, or whenever uid and euid differ. It warns as it does so ("using user/* instead of gui/* domain!"), and the warning is easy to lose in build output. This is the "works at my desk, not over SSH" case: the service starts, but with no window-server access, andlaunchctl print "gui/$(id -u)/..."cannot find it. Look inuser/$(id -u)instead.
Because it is only launchd underneath, the normal tools work:
launchctl print "gui/$(id -u)/homebrew.mxcl.postgresql@16"
launchctl kickstart -k "gui/$(id -u)/homebrew.mxcl.postgresql@16"
Never mix
sudoand non-sudofor the same formula. They write to different directories and different domains, and neither invocation can see the other's plist. The result is two copies of the service fighting over the same port or data directory, withbrew services listshowing only one of them. Pick one — user agent for development,sudodaemon for something that must survive logout — and stay with it.
A user agent stops when you log out. If a service needs to run headless (a database on a build machine, say), it has to be a
sudo-installed daemon.
Logging
There are two distinct logging channels, and they are easy to confuse.
-
The job's own stdout/stderr go wherever
StandardOutPath/StandardErrorPathpoint. If those keys are absent, the output is discarded (it does not go to the unified log). Set them explicitly for any job you want to debug. -
launchd's own messages about the job — load failures, throttling, exit codes — flow through the unified logging system (the
os_logframework introduced in macOS 10.12). There is no flat/var/log/system.logstory any more for most of this; query it with/usr/bin/log.
# Stream live, filtered to your process
/usr/bin/log stream --predicate 'process == "sync-agent"' --info
# Stream everything launchd says, filtered by your label substring
/usr/bin/log stream --predicate 'process == "launchd"' --info | grep com.example.sync
# Show the last 30 minutes for a process
/usr/bin/log show --predicate 'process == "sync-agent"' --last 30m --info
# Subsystem/category filtering (when the program adopts os_log)
/usr/bin/log show --predicate 'subsystem == "com.example.sync"' --last 1h
Spell it
/usr/bin/login anything launchd runs. zsh has alogbuiltin; macOS disables it in/etc/zshrc, which only interactive shells read. A barelog show ...therefore works when you type it at a prompt and dies withzsh:log:1: too many argumentsin a non-interactive shell — which is exactly how launchd runs your job's script, so the command fails precisely where you need it.bashandshhave no such builtin, but the absolute path is unambiguous everywhere.
Console.app provides a GUI over the same unified log — useful for browsing live messages and crash reports without crafting predicates. Crash reports also land in
~/Library/Logs/DiagnosticReports/(user) and/Library/Logs/DiagnosticReports/(system).
SIP, TCC, and Code Signing
Three macOS protection layers routinely interfere with launchd work. A fourth — Background Task Management — is covered under Login Items and SMAppService above.
-
System Integrity Protection (SIP) makes
/System/Library/...read-only even to root, and protects Apple's own daemons from being unloaded. You cannotbootouta SIP-protected Apple job, and you cannot install your own plist there. Keep custom jobs in/Library/...(admin) or~/Library/...(user). SIP status:csrutil status. -
Transparency, Consent, and Control (TCC) gates access to protected resources (Documents, Desktop, Downloads, the camera, Full Disk Access, etc.). A daemon or agent that touches these can fail silently with Operation not permitted (1) even when file permissions look correct. The fix is to grant the executable (or the controlling app) the relevant permission in System Settings → Privacy & Security, commonly Full Disk Access for backup-style jobs. TCC prompts may not surface for background daemons, so the grant often has to be made manually in advance.
Code Signing, Quarantine, and Notarisation
A third protection layer bites specifically on Apple Silicon: every executable must carry a valid signature to run at all, even an ad-hoc one. A binary you compiled locally is signed automatically by the toolchain; one you downloaded, copied from another machine, or patched after signing is not.
# What is this binary signed with?
codesign -dv --verbose=4 /usr/local/bin/sync-agent
# Ad-hoc sign (the "-" identity) — enough to execute on Apple Silicon
codesign -s - --force /usr/local/bin/sync-agent
# Sign properly for distribution, with a hardened runtime
codesign -s "Developer ID Application: Example Ltd (TEAMID)" \
--options runtime --timestamp --force /usr/local/bin/sync-agent
# Did the signature survive whatever you did to the file?
codesign --verify --deep --strict --verbose=2 /usr/local/bin/sync-agent
Downloads carry a quarantine flag that Gatekeeper checks on first use:
# Is it quarantined?
xattr -l /usr/local/bin/sync-agent # look for com.apple.quarantine
# Clear it (only for something you actually trust — prefer notarising instead)
xattr -d com.apple.quarantine /usr/local/bin/sync-agent
# How would Gatekeeper assess it?
spctl -a -t exec -vv /usr/local/bin/sync-agent
Read
spctlcarefully on a bare CLI binary: it answers "rejected (the code is valid but does not seem to be an app)" for anything that is not an app bundle — Apple's own/bin/sleepincluded. That is Gatekeeper declining to assess a non-app, not a signing failure. For a command-line tool,codesign --verifyplus a quarantine check is the meaningful test;spctlearns its keep on.apps and installers.
| Symptom | Likely cause | Fix |
|---|---|---|
Killed: 9 immediately, no output |
Invalid or missing signature (Apple Silicon) | codesign -s - --force <binary> |
| Job loads, exits non-zero at once, code-signing error in the log | Signature broken by a post-sign edit (strip, install_name_tool) |
Re-sign after every modification |
Bootstrap failed: 5 on a downloaded binary |
Quarantine / Gatekeeper rejection | Clear quarantine, or notarise and staple |
| Works locally, fails on a colleague's Mac | Not the signature travelling badly — an ad-hoc signature survives an unchanged copy. It is Gatekeeper rejecting quarantined code with no Developer ID | Developer ID + --options runtime + notarise and staple |
# Watch for signing rejections while you reproduce
/usr/bin/log stream --predicate 'process == "taskgated" OR process == "syspolicyd"' --info
Signing is not optional plumbing on Apple Silicon — it is a hard requirement enforced by the kernel. Any build step that rewrites the binary (stripping symbols, rewriting install names, injecting a version string) invalidates the signature, so signing must be the last thing your build does.
Quick Reference
Most-Used Commands (modern, with legacy mapping)
| Task | Modern | Legacy |
|---|---|---|
| Load (this boot) | launchctl bootstrap <domain> <plist> |
launchctl load <plist> |
| Unload (this boot) | launchctl bootout <domain>/<label> |
launchctl unload <plist> |
| Start now | launchctl kickstart <domain>/<label> |
launchctl start <label> |
| Restart now | launchctl kickstart -k <domain>/<label> |
— |
| Stop | launchctl kill TERM <domain>/<label> |
launchctl stop <label> |
| Enable (persist) | launchctl enable <domain>/<label> |
launchctl load -w <plist> |
| Disable (persist) | launchctl disable <domain>/<label> |
launchctl unload -w <plist> |
| Inspect a job | launchctl print <domain>/<label> |
launchctl list <label> |
| Why is it running? | launchctl blame <domain>/<label> |
— |
| List jobs | launchctl print <domain> |
launchctl list |
| List disabled | launchctl print-disabled <domain> |
— |
| Dump all state | launchctl dumpstate > /tmp/state.txt |
— |
| Inspect a PID | sudo launchctl procinfo <pid> |
— |
| Run in a user's context | sudo launchctl asuser <uid> sudo -u <user> <cmd> |
launchctl bsexec |
| Session env var | launchctl setenv KEY value |
— |
| Show limits | launchctl limit [maxfiles] |
— |
| Persistent config | sudo launchctl config user path <PATH> |
— |
| Restart userspace | sudo launchctl reboot userspace |
— |
| Validate plist | plutil -lint <plist> |
— |
| Sign a binary (ad-hoc) | codesign -s - --force <binary> |
— |
| Homebrew service status | brew services list |
— |
Domain Targets
| Target | Context |
|---|---|
system |
LaunchDaemons (root, machine-wide) |
gui/$(id -u) |
LaunchAgents with display access (logged-in user) |
user/$(id -u) |
Per-user, non-GUI (LimitLoadToSessionType = Background) |
pid/<pid> |
A specific process's domain |
Plist Directories
| Directory | Kind / scope |
|---|---|
~/Library/LaunchAgents |
Agents, this user |
/Library/LaunchAgents |
Agents, all users |
/Library/LaunchDaemons |
Daemons, system |
/System/Library/... |
Apple-owned (do not touch) |
<App>.app/Contents/Library/LaunchAgents |
Bundled agent, registered via SMAppService |
<App>.app/Contents/Library/LaunchDaemons |
Bundled daemon, registered via SMAppService |
Common Issues and Solutions
Service won't load (bootstrap fails)
# 1. Validate the plist first
plutil -lint /Library/LaunchDaemons/com.example.sync.plist
# 2. Daemons MUST be root-owned and not group/other-writable
sudo chown root:wheel /Library/LaunchDaemons/com.example.sync.plist
sudo chmod 644 /Library/LaunchDaemons/com.example.sync.plist
launchd refuses to load a system daemon whose plist is owned by a non-root user or is group/world-writable — it reports Path had bad ownership/permissions. The plist must be owned by root:wheel with mode 644 (no write bit for group or other). The same hygiene applies to the executable it launches.
"Input/output error" (errno 5) on bootstrap
This almost always means the job is already loaded in that domain — bootstrap is not idempotent. Bootstrapping a service whose label is already present in the domain fails with Bootstrap failed: 5: Input/output error.
# Tear it down, then bootstrap again
sudo launchctl bootout system/com.example.sync 2>/dev/null
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.sync.plist
Error 5 is something of a launchd catch-all, so if bootout then bootstrap still fails, check the next-likeliest causes: the label is disabled (launchctl enable system/com.example.sync before bootstrapping), the bundle was rejected by code-signing or the App Sandbox, or the program the job runs lives under a TCC-protected path (~/Documents, ~/Desktop, ~/Downloads). Each is a thread to pull rather than a full diagnosis here.
KeepAlive thrash / throttling
A job that exits immediately while KeepAlive is set will be respawned in a tight loop. launchd rate-limits this: a job will not be respawned more than once every 10 seconds by default. Raising ThrottleInterval widens that gap.
<key>ThrottleInterval</key>
<integer>60</integer> <!-- at most one respawn per minute -->
Watch it happen in the unified log:
/usr/bin/log stream --predicate 'process == "launchd"' --info | grep com.example.sync
# ... "Service only ran for 0 seconds. Pushing respawn out by 10 seconds."
# ... "service spawn deferred by 10 seconds due to throttle"
If the job is meant to be short-lived, drop KeepAlive and use RunAtLoad, StartInterval, or StartCalendarInterval instead.
"Operation not permitted" (SIP / TCC)
| Symptom | Cause | Fix |
|---|---|---|
Cannot bootout an Apple daemon |
SIP protects /System/Library jobs |
Not removable; leave it |
Operation not permitted writing to ~/Documents etc. |
TCC | Grant the executable Full Disk Access in System Settings → Privacy & Security |
Install to /System/Library/... fails read-only |
SIP | Use /Library/... instead |
Killed: 9 with no output (Apple Silicon) |
Invalid or absent code signature | codesign -s - --force <binary> |
Check SIP with csrutil status. Do not disable SIP to work around this — relocate the job or grant the TCC permission.
Job runs but exits immediately
# Inspect last exit status and reason
sudo launchctl print system/com.example.sync | grep -E 'state|last exit'
# Make sure output is captured, then read it
# (add StandardOutPath/StandardErrorPath to the plist if missing)
tail -f /var/log/sync-agent.err
# Run the command by hand in the job's context to see the real error
sudo -u _sync /usr/local/bin/sync-agent --config /usr/local/etc/sync.toml
Common causes: relative paths (launchd does not inherit your shell's PATH or cwd — use absolute paths and set WorkingDirectory); a missing EnvironmentVariables value the program expects; or the executable bit not set.
StartCalendarInterval not firing
Missed runs during sleep are not silently dropped. launchd.plist(5) is explicit: "Unlike cron which skips job invocations when the computer is asleep, launchd will start the job the next time the computer wakes up." A job scheduled for 02:30 on a machine that sleeps through it fires on wake instead.
The catch is the sentence after it: if several slots elapse before the machine wakes, those events are coalesced into one. A job scheduled every 15 minutes across an eight-hour sleep fires once at wake, not thirty-two times — so a job that assumes one run per slot will under-count. Time spent fully powered off is a separate matter, and no catch-up is promised there.
# Confirm the schedule parsed as expected
sudo launchctl print system/com.example.sync | grep -A5 -i calendar
If a job must actually run at a wall-clock moment rather than at the next wake, wake the machine for it (pmset repeat, or a wake schedule) instead of relying on catch-up. And make the job idempotent, since coalescing means one invocation may have to stand in for several.
StartIntervalandStartCalendarIntervalare, in the man page's words, "not aware of each other". Setting both gives you two independent triggers, not one merged schedule.
Wrong session: agent has no GUI access
An agent bootstrapped into user/<uid> instead of gui/<uid> runs without window-server access, so anything touching the display (notifications, AppleScript UI, screenshots) fails. For agents that need the desktop, always target gui/$(id -u) and place the plist in a LaunchAgents directory.
Job is enabled and loaded but silently never runs (macOS 13+)
launchctl print shows the job, print-disabled reports it => enabled (or omits it), and it still does not fire. On Ventura and later, check Background Task Management:
System Settings → General → Login Items & Extensions → Allow in the Background
An item switched off there is blocked by BTM, and bootstrap still reports success — there is no error to find in the log. Add AssociatedBundleIdentifiers so the entry carries a name the user recognises, and expect a fresh approval prompt after any change to the job's signing identity.
Reinstalled job never starts, no errors anywhere
A disable override outlives the plist it referred to. Deleting the file and reinstalling leaves the label disabled, and bootstrap succeeds regardless.
sudo launchctl print-disabled system | grep com.example.sync
# => "com.example.sync" => disabled <-- the value is the answer, not the hit
# => "com.example.sync" => enabled <-- this one is fine; look elsewhere
sudo launchctl enable system/com.example.sync
sudo launchctl kickstart -k system/com.example.sync
Clear the override as part of uninstalling, not as a debugging step six months later.
Files created with the wrong permissions
An Umask integer is read as decimal: <integer>22</integer> is octal 026, not 022 — so a file created from mode 0666 comes out 0640 instead of 0644. Writing it as a string avoids the trap entirely, because a string is parsed base-8: <string>022</string> is octal 022. SockPathMode is integer-only, so it is always decimal.
Note the inconsistency before you go hunting in the wrong place: the plist key is decimal, but launchctl config <domain> umask parses octal. If the umask is wrong, check which of the two set it.
echo $(( 8#022 )) $(( 8#027 )) $(( 8#077 )) # => 18 23 63
python3 -c 'print(0o022, 0o027, 0o077)' # => 18 23 63
Homebrew service running twice, or not at all
brew services and sudo brew services maintain separate installations in separate domains and cannot see each other.
ls -l ~/Library/LaunchAgents/homebrew.mxcl.*
ls -l /Library/LaunchDaemons/homebrew.mxcl.*
If the same formula appears in both, stop it both ways (brew services stop <f> and sudo brew services stop <f>), then start it once, the way you actually want it.
Socket-activated job starts but sees no connections
# Is launchd actually holding the listener?
sudo launchctl print system/com.example.sync | grep -A10 -i 'sockets\|endpoints'
Usual causes: the name passed to launch_activate_socket does not match the key in the Sockets dict; the job calls socket()/bind() itself instead of accepting on the inherited descriptor, so it is listening somewhere launchd is not; or it closes the inherited fd. RunAtLoad is a red herring here — it launches the job early but does not interfere with the hand-off.
osascript / notifications fail when run from a daemon
The system domain has no window server. Re-enter the user's GUI context and drop privileges:
uid=$(id -u mike)
sudo launchctl asuser "$uid" sudo -u mike osascript -e 'display notification "hello"'
asuser alone leaves the command running as root; sudo -u alone leaves it in the wrong bootstrap context. Both are needed.
Related Topics
To complement this launchctl cheatsheet, consider exploring these related topics:
- systemd - The Linux service manager; the closest analogue, with units, timers, and socket activation covering the same ground
- macOS Network Tools - Diagnosing the network-facing daemons and agents you have just learned to supervise
- zsh - The default macOS shell, and where
PATHand environment differences between your terminal andlaunchdfirst bite - SSH & SSH Config - Remote administration of Macs, where the system-domain-versus-GUI-session distinction matters most
- Shell Scripting - Writing the idempotent, absolute-path scripts that
launchdjobs demand - Cron & Scheduled Jobs - The scheduling model
StartCalendarIntervalreplaces, and its persistence semantics