A loop of mine walked eight launchd job plists to read the schedule out of each one. Afterwards every one of the eight files looked like this:
<?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">
<array>
<dict>
<key>Hour</key>
<integer>7</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>20</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
</array>
</plist>
That is the StartCalendarInterval array, and nothing else. Label was gone. ProgramArguments was gone. The loop printed no error, and it exited 0.
The reading line was this:
plutil -extract StartCalendarInterval xml1 "$plist"
The step I spent time on was the wrong one
The loop also had a write step, so that is where I looked. Eight files damaged in the same way says "the writer ran with a bad value", and the writer was the only thing in the script that was supposed to touch the files at all. I read it twice. I also considered launchd rewriting plists behind me, which it does do to some files, and which would explain damage that appears without anybody in my code asking for it.
Nobody audits the read step. A command whose whole job is to print a key does not go on the suspect list, and mine sat one line above the part I kept re-reading.
Three branches on the same file
Same 28-line plist, three commands, on macOS 15.6.1 (Darwin 24.6.0) with the system plutil. I checked the file with shasum before and after each one.
| Command | Prints | Exit | File afterwards |
|---|---|---|---|
plutil -extract StartCalendarInterval xml1 f.plist |
nothing | 0 | 28 lines → 18 lines, only the array, Label 0 hits |
plutil -extract StartCalendarInterval raw -o - f.plist |
2 |
0 | byte-identical |
plutil -p f.plist |
the whole dict | 0 | byte-identical |
So -extract did not fail. It did exactly what it was asked, and what it was asked was not what I thought.
The layer that owns this is the output destination, not the extract
-extract is a transform, not a print. It produces a new plist containing the value at the key path, and then it has to put that plist somewhere. -o names where. Leave -o off and the destination defaults to the file you passed in, which is the same contract plutil -convert has — and -convert is a command everyone already expects to modify a file in place.
Two more branches, same setup:
$ plutil -extract StartCalendarInterval xml1 -o out.plist f.plist
$ shasum f.plist # unchanged; out.plist is the 18-line array
$ plutil -extract StartCalendarInterval json f.plist
$ cat f.plist
[{"Hour":7,"Minute":0},{"Hour":20,"Minute":0}]
The json branch is the same mechanism with the damage more visible: the destination file now holds JSON under a .plist name, so the next reader fails at parse rather than at a missing key.
The man page is part of why this is easy to walk into. -extract is documented as "Outputs the value at keypath in the property list as a new plist of type fmt", and "outputs" reads as stdout. -o is documented separately as "an alternate path name for the result of the -convert operation; this option is only useful with a single file to be converted". By that text, -o has nothing to do with -extract. In practice it is the only thing standing between -extract and your input file.
The failing runs are the ones that survive
This is the part that kept me looking in the wrong place, and it is the reason the bug is quiet.
$ plutil -extract NoSuchKey xml1 d.plist
d.plist: Could not extract value, error: No value at that key path or invalid key path: NoSuchKey
$ echo $?
1
$ shasum d.plist # unchanged
A missing key gives you a loud error, exit 1, and an intact file. The key being present gives you silence, exit 0, and an overwritten file. So in a loop over many files, the ones that "worked" are the ones that got destroyed, and the ones that errored are the ones still holding their contents.
That inverts the signal you would normally reach for. set -e does not help: the destructive branch is the successful one. Checking exit status does not help either, because it is 0 in the branch you care about — and 0 in both harmless branches too, so it does not rank the runs at all. A 2>/dev/null on the loop, which I have written plenty of times to quiet the missing-key noise, hides the only output that any of this produces.
Read a key without writing anything
plutil -p "$plist" # whole thing, human-readable
plutil -extract Label raw -o - "$plist" # one scalar
plutil -extract StartCalendarInterval xml1 -o - "$plist" # one container
-o - sends the result to stdout, which is what I had assumed -extract did on its own. One thing to know about raw: on a container it prints the element count, so the array above comes out as 2 rather than as its contents. That is a documented property of raw, not a failure, but it is a good way to convince yourself the command is working while reading nothing useful — use xml1 or json with -o - for anything that is not a scalar.
If you would rather not think about plutil argument order at all, /usr/libexec/PlistBuddy -c "Print :Label" "$plist" and Python's plistlib both read without a write path.
What this was tested on, and what it was not
macOS 15.6.1, Darwin 24.6.0, system plutil, on plists I copied into a scratch directory for the measurement. I have not checked older macOS releases, so I cannot tell you when this default appeared or whether the man page ever described it. I also have not tested -insert, -replace or -remove here; those are documented mutators, so in-place writing is the expected behavior and not a surprise worth measuring.
The generalization I would make is narrower than "check your read commands". It is that a CLI subcommand which produces a value has to have an output destination, and when the tool's other subcommands default that destination to the input file, the reading subcommand inherits it. plutil -convert sets that expectation for the whole tool, and -extract quietly follows it. If you have a tool in that shape, the cheap check is to shasum the input before and after a read you believe is harmless — that is how the three branches above got separated, and it takes one line in a loop that already exists.
Top comments (0)