find … | xargs … is one of the first pipelines you learn, because it reads like a sentence: find these files, then do this to them. It works on your machine, it works in the demo, and then one day a file named old report.log shows up and xargs quietly does the wrong thing to two files that don’t exist.
This is the safe version. Both ways it betrays you stay in, because both are going to happen to you.
The pipeline, and the day it turns on you
Make three log files. One of them has a space in its name — completely normal, your designer does it every day:
$ cd "$(mktemp -d)"
$ touch normal.log "my report.log" other.log
$ ls -1
my report.log
normal.log
other.log
Now the pipeline everybody pastes. Find the logs, list them:
$ find . -name '*.log' | xargs ls -l
ls: cannot access './my': No such file or directory
ls: cannot access 'report.log': No such file or directory
-rw-r--r-- 1 you you 0 Jun 30 10:19 ./normal.log
-rw-r--r-- 1 you you 0 Jun 30 10:19 ./other.log
$ echo "exit=$?"
exit=123
Read that carefully. my report.log became two arguments — ./my and report.log — and ls went looking for two files that were never there. xargs splits its input on whitespace by default, and a space inside a filename is whitespace like any other. Now imagine the command was rm instead of ls. You didn’t delete my report.log; you tried to delete a file called my and a file called report.log, and on a less lucky day one of those exists.
This is the whole problem: filenames can contain spaces, tabs, and newlines, and the one character they can’t contain is a NUL byte. So the fix is to delimit on the one byte that’s safe.
The fix: delimit on NUL with -print0 and -0
find -print0 ends each result with a NUL byte instead of a newline. xargs -0 reads NUL-delimited input. Together they pass filenames through whole, spaces and all:
$ find . -name '*.log' -print0 | xargs -0 ls -l
-rw-r--r-- 1 you you 0 Jun 30 10:19 ./my report.log
-rw-r--r-- 1 you you 0 Jun 30 10:19 ./normal.log
-rw-r--r-- 1 you you 0 Jun 30 10:19 ./other.log
$ echo "exit=$?"
exit=0
my report.log survived as one file. You’ll know it worked when a filename with a space lists as a single line and the exit code is 0 instead of 123.
The rule to memorize: if the left side of the pipe is find, the right side is xargs -0, and find gets -print0. They come as a pair. (GNU grep -lZ, git ls-files -z, and friends emit NUL too — anything feeding xargs -0 needs the matching -z/-Z/-print0 flag.)
The second footgun: empty input still runs the command
Here’s the one that bites in scripts, long after the spaces are handled. By default, GNU xargs runs your command once even when it gets no input at all — with no arguments:
$ echo -n "" | xargs ls
my report.log
normal.log
other.log
$ echo "exit=$?"
exit=0
Nothing came in on the pipe, but ls ran anyway with zero arguments, so it listed the current directory. With ls that’s harmless. With something like xargs rm -rf after a find that matched nothing, “run with no arguments” can mean “operate on the current directory.” That is a genuinely bad afternoon.
The fix is -r (long form --no-run-if-empty): don’t run the command at all if there’s no input.
$ echo -n "" | xargs -r ls
$ echo "exit=$?"
exit=0
No output, because ls never ran. You’ll know it worked when an empty pipe produces nothing instead of accidentally listing or acting on your whole directory. (BSD/macOS xargs already skips on empty input, so -r is a no-op there — but add it anyway so your scripts behave the same everywhere.)
Two flags worth knowing: -I and -n1
By default xargs crams all the arguments onto one command line:
$ echo "a b c d e" | xargs echo "args:"
args: a b c d e
That’s efficient — one rm for a thousand files instead of a thousand rms. But sometimes you need the item somewhere other than the end, or one invocation per item.
-I{} gives the argument a name so you can place it mid-command (and implies one-per-line):
$ find . -name '*.txt' -print0 | xargs -0 -I{} echo "processing -> {} <- done"
processing -> ./a.txt <- done
processing -> ./b.txt <- done
processing -> ./c.txt <- done
-n1 keeps the default end-placement but runs the command once per argument:
$ echo "a b c d e" | xargs -n1 echo "arg:"
arg: a
arg: b
arg: c
arg: d
arg: e
And once you’re one-per-item, -P runs several at once. -P4 -n1 keeps up to four going in parallel (so the output order is no longer guaranteed):
$ echo "1 2 3 4 5 6" | xargs -n1 -P4 echo "worker did"
worker did 1
worker did 2
worker did 3
worker did 4
worker did 5
worker did 6
That’s the free parallelism people reach for & and wait to fake. With -P it’s one flag — only remember the output can interleave.
The whole safe pattern, tested
Here is the shape to reach for, wired so it can’t split a filename and can’t fire on an empty match. This block is opted into our test harness (lh:run) and runs on every build in a locked-down, no-network sandbox, so the version you’re reading is the version that passed:
```bash lh:run #!/usr/bin/env bash set -euo pipefail
cd “$(mktemp -d)” touch “app.log” “old report.log” “debug.log”
echo “==> files, one with a space in the name:” find . -name ‘*.log’ | sort
echo “==> the safe pattern: NUL-delimit (-print0), NUL-read (-0), skip if empty (-r)” find . -name ‘*.log’ -print0 | xargs -0 -r -n1 echo “ keeping:”
count=$(find . -name ‘*.log’ -print0 | xargs -0 -r -n1 echo | wc -l) echo “==> handled $count files, including the one with a space” test “$count” -eq 3 echo “done” ```
All the console output above is real, captured from xargs (GNU findutils) 4.9.0 on bash 5.2.21.
When this goes wrong
- You used
-print0but forgot-0(or vice versa). Then the NUL bytes show up as literal\0garbage or the whole stream arrives as one giant argument. They’re a matched pair; change both or neither. -I{}is slower than you expect. It forces one process per item — fine for ten files, painful for a hundred thousand. When you don’t need mid-line placement, plain batching (no-I) is far faster.- macOS doesn’t have
-print0’s friends everywhere. Old BSD tools vary; if a flag is missing, the portable escape hatch isfind … -exec cmd {} +, which handles spaces natively without xargs at all. Reach for that when you can’t trust the input format.
Two flags fix the two bugs: -0 so a space can’t split a file, -r so an empty match can’t fire the command. Pair them with find -print0 and the pipeline that read like a sentence finally means what it says.