xargs splits on the wrong things¶
Level: 201 · for anyone who has piped find into something
One line: xargs splits its input on spaces and tabs as well as newlines, and processes ', " and \ as quoting before it hands anything over — so a file called O'Brien.txt stops the pipeline, and find … -print0 | xargs -0 is not a refinement but the only correct form.
Where this page starts¶
The find page establishes that a filename is a bag of bytes with two forbidden values, 0x00 and /, so a newline in a name is legal and find … | wc -l counts the wrong thing. This page is about what happens next — because the tool on the other end of that pipe makes the problem worse in two ways find cannot warn you about.
In the terminal¶
Verified output of xargs_separator_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
1. SEVEN FILES. COUNT THEM THREE WAYS AND GET THREE ANSWERS
ls files | wc -l : 8
find files -type f | wc -l : 8
find files -type f -print0 | tr -dc "\\0" | wc -c : 7
Eight, eight, seven. One of the names contains a newline, so any count
of LINES is a count of the wrong thing. Only the NUL-separated form
answers 'how many files', because NUL is the one byte a filename
cannot contain.
2. THE DEFAULT SEPARATOR IS WHITESPACE, NOT NEWLINE
$ printf "with space.txt\n" | xargs -n99 echo COUNT:
COUNT: with space.txt
$ printf "with space.txt\n" | xargs -n1 echo ARG:
ARG: with
ARG: space.txt
One filename went in and TWO arguments came out. xargs splits on
spaces and tabs as well as newlines, so 'with space.txt' is 'with' and
'space.txt' — two files that do not exist.
3. AND THE QUOTE CHARACTERS ARE SPECIAL
$ printf "O%sBrien.txt\\n" "'" | xargs echo
(refused — exit 1)
An apostrophe in a filename is enough. No spaces, no newline, nothing
exotic — xargs processes ' and " and backslash as quoting before it
hands anything over, so a perfectly ordinary Irish surname stops the
pipeline. Both xargs refuse; they word it differently, so only the
status is shown here. GNU's message names the fix, BSD's does not.
4. -print0 AND -0: THE PAIR THAT TURNS ALL OF IT OFF
$ find files -type f -print0 | xargs -0 -n1 echo FILE: | wc -l
8
Eight lines for seven files, and this time that is honest: echo is
printing a name that genuinely contains a newline. With -0 there is no
splitting on spaces, no quote processing and no escape processing —
the only separator is the one byte that cannot appear in a name.
5. THE SIZE LIMIT IS COUNTED IN BYTES, SO THE ENCODING DECIDES
THE NUMBER OF BATCHES
Two directories, ten files each, every name SEVEN CHARACTERS long:
ascii/aaaaa01 bytes: 7
accent/ééééé01 bytes: 12
ascii at -s 200 : 1
accent at -s 200 : 2
batch(es). Same number of files, same number of characters in every
name, and the accented directory needs more runs — because -s is a
BYTE budget and é costs two. How many times your command runs is a
function of how your filenames are spelled.
6. WHY THE BATCH COUNT IS NOT A DETAIL
xargs runs the command once PER BATCH. That is invisible for rm and
chmod, and it is a bug for anything that starts fresh each time:
find . -print0 | xargs -0 tar -cf out.tar <- batch 2 OVERWRITES
find . -print0 | xargs -0 sort > sorted <- sorted per batch
Neither reports anything. The archive is simply short, and whether it
is short depends on whether the filenames were ASCII.
The fix is not a bigger -s. It is to hand the LIST to a command that
reads a list, instead of expanding it onto a command line:
find . -print0 | tar --null -cf out.tar -T -
-T - reads names from stdin and --null says they are NUL-separated, so
there is one tar, no batching, and no separator to get wrong. Verified
on bsdtar 3.5.3 and GNU tar 1.35.
The three defaults, and why each is wrong for filenames¶
| Default | What it does | Why it breaks |
|---|---|---|
| splits on whitespace | space and tab end an argument, as well as newline | with space.txt becomes two arguments naming two files that do not exist |
| processes quotes | ', " and \ are quoting characters |
O'Brien.txt is an unterminated quote and xargs refuses — no spaces required |
| batches by byte budget | runs the command once per ARG_MAX-sized batch |
how many times your command runs depends on how your filenames are spelled |
-0 turns off the first two completely. Nothing turns off the third.
The quote row is the one people do not expect, and it is worth dwelling on: it is not an edge case about weird filenames. Apostrophes are in ordinary names, in every language that has them, and a single one in a single file will stop a find … | xargs pipeline dead. Both xargs implementations refuse — GNU's message names the fix ("by default quotes are special to xargs unless you use the -0 option") and BSD's does not ("unterminated quote"), which is why the recorded example above shows the exit status rather than the wording.
The byte budget is the character-data part¶
Section 5 of the example is the reason this page is in this chapter rather than a shell-scripting one. Two directories, ten files each, every filename exactly seven characters long. The ASCII one takes one batch; the accented one takes two, because -s is a budget in bytes and each é costs two of them.
That is a property of the text, changing the control flow of a pipeline. For rm or chmod it makes no difference. For anything that starts fresh on each run it is a silent data-loss bug:
find . -print0 | xargs -0 tar -cf out.tar # batch 2 OVERWRITES batch 1's archive
find . -print0 | xargs -0 sort > sorted # sorted within each batch, not overall
Nothing reports it. The archive is simply short — and whether it is short depends on whether the filenames happened to be ASCII, which is why it works in testing and fails on the customer with the accented surname.
The fix is not a bigger -s. It is to stop using a command line as the transport:
-T - reads the names from stdin and --null says they are NUL-separated, so there is one tar, no batching, and no separator to get wrong. Verified on bsdtar 3.5.3 and GNU tar 1.35.
The part that is not the same on both machines¶
macOS (BSD) Ubuntu (GNU)
: | xargs echo RAN (nothing) RAN
: | xargs -r echo RAN (nothing) (nothing)
find files -type f | xargs ls -d 0 of 7 survived 2 of 7 survived
(the seven awkward names, no -print0)
getconf ARG_MAX 1048576 2097152
xargs --show-limits unrecognized prints the budget
Empty input is the classic one. Hand GNU xargs nothing and it runs your command anyway, once, with no arguments — find /nonexistent | xargs rm -rf /tmp/build is the shape of accident that follows. BSD xargs does not. -r (--no-run-if-empty) makes GNU behave like BSD and is accepted by BSD too, so write -r and it is portable; omit it and your script has two behaviours.
The second row is the same catastrophe with two different numbers, which is worth seeing precisely because neither is "safe": the default pipeline mangles the names on both platforms, it just mangles a different number of them.
If you are coming from Python or ABAP¶
Python: subprocess.run([cmd, *names]) is xargs with none of its problems — the list is passed as a list, so there is no separator, no quoting pass, and no re-parsing. That is the general lesson rather than a Python one: xargs exists because a pipe carries bytes and a command line wants a list, and every bug on this page is in the conversion between the two. When you must shell out, pass a list and never a string. os.scandir() entries go straight into that list, so the round trip through text never happens. If you hit ARG_MAX for real, that is subprocess raising OSError: [Errno 7] Argument list too long — the same budget, reported honestly instead of silently split.
ABAP (Not machine-checked — CI cannot run ABAP.) There is no xargs, and the nearest equivalent is building a command string for SXPG_COMMAND_EXECUTE or an external OS command — which is exactly the unsafe direction this page argues against, because you are constructing a command line by concatenation and every quoting rule above is now yours to get right. Prefer passing a file of names and having the external program read it, which is the tar -T - shape; and remember that the additional-parameters string is bytes on the far side, so the code page of what you concatenate matters.
Try it¶
touch "O'Brien.txt"in an empty directory, thenfind . -type f | xargs ls. Read the error.- The same with
-print0 | xargs -0 ls. : | xargs echo RANon a Mac and on a Linux box. One of them printsRAN.- Make twenty files with accented names and twenty with ASCII names of the same character length, then
find -print0 | xargs -0 -s 200 echo | wc -lon each. The difference is the encoding deciding your control flow.
Practice¶
Four filenames, and how many survive. Create files called plain.txt, two words.txt, O'Brien.txt and quote".txt. Predict how many lines come out of find files -type f | xargs -I{} echo {} — and what xargs's exit status is.
Then say what xargs splits its input on (it is more than newlines), why -print0 | xargs -0 is not a refinement but the only correct form, and what the second correct form is.
Answers
Verified output of xargs_kata_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
THE FOUR FILES
files/O'Brien.txt
files/plain.txt
files/quote".txt
files/two words.txt
1. NAIVE: find | xargs
lines out: 1 of 4 xargs exit: 1
The apostrophe opens a quote that never closes, so xargs consumes the
rest of the input looking for the end of it and then reports an
unterminated quote. One filename broke the whole pipeline -- not just
its own line.
2. WHY: xargs SPLITS ON MORE THAN NEWLINES
a
b
c
d
One line in, four arguments out. xargs splits on spaces and tabs as
well as newlines, so 'two words.txt' was already two files before the
quote problem started.
3. THE FIX, AND IT IS NOT A REFINEMENT
find -print0 | xargs -0 lines out: 4 of 4
NUL is the one byte a filename cannot contain -- the kernel's own API
cannot express it -- so it is the only delimiter that can never occur
in the data. Every other separator is a guess about what people do not
name their files.
4. THE OTHER CORRECT FORM, WITH NO xargs AT ALL
find -exec ... \; lines out: 4 of 4
find hands the name to exec directly, so nothing ever parses it as
text. Use -exec ... + when you want them batched; it is the same
safety with one process instead of four.
WHAT THIS IS REALLY ABOUT
A filename is a bag of bytes with exactly two forbidden values: 00 and
2f. Everything else -- spaces, quotes, newlines, escapes -- is legal,
and every tool that treats a list of filenames as TEXT has to invent a
convention the filesystem never agreed to.
See also¶
find, and filenames that are bytes — where-print0comes from, and why a newline in a name is legalgrepon text that is not ASCII — the other half of the usualfind | xargs greppipelinecutcounts what it is told to count — the same byte budget in a different tool- A code point is not a character — why "seven characters" and "seven bytes" are different measurements