Every Unix pipeline has a gap. Some commands read from stdin. Others expect arguments. And your OS has a hard limit on how long a command line can be. xargs solves both problems — and most people never learn why it exists.

The Gap in the Pipe

The Unix philosophy runs on pipes. Small tools, composed into pipelines, each one reading from stdin and writing to stdout. It's elegant. It works. And then you hit a wall.

find . -name "*.log" | rm

Nothing happens. No error, no output — rm just sits there. It didn't read from stdin because it doesn't know how to. It wants filenames as arguments, on the command line, after the command name. The pipe delivered data to its stdin, and rm ignored every byte of it.

This is the gap. Some commands read stdin. Others read arguments. The pipe connects stdout to stdin, but there is no built-in mechanism that connects stdout to arguments.

That's what xargs does. It reads lines from stdin and passes them as arguments to whatever command you give it.

find . -name "*.log" | xargs rm

Now rm receives the filenames where it expects them: on the command line. xargs consumed stdin and turned it into arguments. Problem solved.

The Other Problem: Argument List Too Long

The stdin/arguments gap is the reason most people discover xargs. But it was actually built to solve a second problem — one that's older and more dangerous.

Every operating system has a maximum length for a command line. On Linux it's typically 2MB. On older systems it was 128KB or less. When you use command substitution to expand a list of files, the entire list becomes a single command-line string:

# This works fine with 100 files
rm $(find . -name "*.log")

# This explodes with 50,000 files
rm $(find . -name "*.log")
# -bash: /bin/rm: Argument list too long

The kernel returns E2BIG. Your command doesn't run. Nothing gets deleted. And if you're in a script, you've just hit a silent failure that only manifests at scale.

xargs solves this by batching automatically. It reads the input, fills up a command line to just under the OS limit, executes it, then reads more input and builds the next command. Ten thousand files might become four rm invocations instead of one — all handled transparently.

# Safe at any scale — xargs handles the batching
find . -name "*.log" | xargs rm

So xargs solves two problems at once: it bridges stdin to arguments, and it batches arguments to avoid hitting the OS limit. Most tutorials only mention the first one. The second is why it was originally written.

Stdin Commands vs Argument Commands

Once you see the distinction, you can't unsee it. There are two kinds of commands in Unix, and every pipeline decision you make depends on knowing which kind you're dealing with.

Stdin commands sit in the middle of pipelines naturally. They read from their input stream, process it, and write to output. Think grep, sed, awk, sort, wc, uniq, tr, cut, head, tail. You can type input into them interactively. They're built for composition.

Argument commands expect their operands on the command line. Think rm, mkdir, touch, cp, mv, chmod, chown, kill, echo. If you pipe data to them, they ignore it. They just print a usage error or do nothing at all.

stdin commands argument commands ──────────────── ────────────────── grep, sed, awk rm, mkdir, touch sort, uniq, wc cp, mv, chmod tr, cut, head chown, kill, echo reads from pipe ───► reads from command line │ │ ┌─────┴─────┐ │ xargs │ │ stdin ──► │ │ arguments │ └────────────┘

The mental test is simple: run the command with no arguments and see what happens. If grep foo sits there waiting for you to type lines, it reads stdin. If mkdir with no arguments prints a usage error, it reads arguments. If it waits, it's a stdin command. If it complains, it's an argument command.

Some commands do both. cat file.txt takes an argument, but echo hello | cat reads stdin. When both modes are available, stdin typically wins when data arrives through a pipe. But these are the minority — most commands live firmly in one camp.

The xargs Flags That Matter

The man page for xargs is long. You need about eight flags.

-I {} — placement control

By default, xargs appends arguments to the end of the command. That works fine for rm and mkdir, but plenty of commands need the argument somewhere in the middle.

# Default: appends to the end
echo "/tmp/work" | xargs mkdir -p
# Equivalent to: mkdir -p /tmp/work

# With -I: place the argument wherever you want
cat files.txt | xargs -I {} cp {} /backup/{}
# Equivalent to: cp somefile.txt /backup/somefile.txt

The {} is a placeholder. Every occurrence of {} in the command gets replaced with the current input line. Note that -I also implies one line at a time — it switches xargs from batch mode to per-line mode.

-0 — null delimiter

By default, xargs splits on whitespace. This is fine until you have a file called my holiday photos or John's Report.pdf. Suddenly xargs treats my, holiday, and photos as three separate arguments, and your command does something catastrophic to files that don't exist — or worse, files that do.

# BROKEN: spaces and quotes in filenames will destroy you
find . -name "*.pdf" | xargs rm

# SAFE: null-delimited pipeline
find . -name "*.pdf" -print0 | xargs -0 rm

find -print0 outputs filenames separated by null bytes instead of newlines. xargs -0 reads null-delimited input instead of whitespace-delimited input. Null bytes can't appear in filenames, so the parsing is bulletproof.

But the problem is worse than spaces. Without -0, xargs doesn't just split on whitespace — it also interprets quotes and backslashes in the input. It's running its own mini-parser on every line. A file called it's done.txt breaks not because of the space, but because xargs sees that apostrophe as an unclosed single quote and either errors or eats subsequent filenames as part of the "quoted string". A filename with a backslash — common on paths copied from Windows — gets the character after it treated as an escape sequence.

# This file exists:
touch "it's done.txt"

# Without -0: xargs sees an unclosed quote
echo "it's done.txt" | xargs ls
# xargs: unmatched single quote; ...

# With -0: no parsing, no interpretation, just bytes
printf "it's done.txt\0" | xargs -0 ls
# it's done.txt

This is the deeper reason -0 matters. It doesn't just change the delimiter — it turns off the entire quoting parser. The input becomes raw bytes separated by nulls, with no metacharacters, no escaping, no interpretation at all. That's what makes it bulletproof, not just "safer".

Rule of thumb

If the input could contain filenames you didn't create yourself, always use -print0 | xargs -0. Everyone learns this the hard way exactly once.

-n — batch size

By default, xargs tries to cram as many arguments as possible into a single invocation — up to the operating system's maximum command-line length (usually 128KB to 2MB depending on the OS). That's efficient, but sometimes you want to control the batch size.

# Run grep with 5 files at a time
find . -name "*.go" | xargs -n 5 grep "TODO"

# Run one file at a time
find . -name "*.go" | xargs -n 1 go vet

This matters in two situations: when you're hitting argument length limits on enormous file lists, and when the command behaves differently depending on how many arguments it receives. diff, for instance, takes exactly two files — you'd need -n 2 if you were feeding it pairs from a list.

-P — parallel execution

This is the one that surprises people. xargs can run multiple invocations in parallel.

# Compress files using 4 parallel gzip processes
find . -name "*.log" -print0 | xargs -0 -P 4 -n 1 gzip

# Run linting across 8 cores
find . -name "*.go" -print0 | xargs -0 -P 8 -n 1 go vet

# Fetch a list of URLs in parallel
cat urls.txt | xargs -P 10 -I {} curl -sO {}

-P 4 runs up to four processes simultaneously. Combined with -n 1 (one argument per invocation), it turns xargs into a simple job runner. It's not a replacement for GNU Parallel or a proper task queue, but for quick-and-dirty parallelism from the command line, it's hard to beat.

Use -P 0 to run as many processes as possible. Use with caution.

-p — interactive confirmation

The dry-run flag. Before each invocation, xargs prints the command it's about to run and waits for you to confirm.

find . -name "*.tmp" | xargs -p rm
# rm ./cache/old.tmp ./logs/debug.tmp?...y

Useful when you've built a destructive pipeline and want to sanity-check it before pulling the trigger. Not something you'd use in a script, but it's saved files more than once.

-L — lines, not arguments

This is the flag people confuse with -n. The difference matters. -n counts arguments (whitespace-separated tokens). -L counts lines.

# Input: three lines, some with multiple words
echo -e "one two\nthree four\nfive six" | xargs -n 1 echo "arg:"
# arg: one
# arg: two
# arg: three
# arg: four
# arg: five
# arg: six

echo -e "one two\nthree four\nfive six" | xargs -L 1 echo "arg:"
# arg: one two
# arg: three four
# arg: five six

-n 1 gave six invocations — one per word. -L 1 gave three invocations — one per line, preserving each line as a single argument string. When your input is structured by lines (as it usually is from find or a file list), -L is often what you actually want.

-r — don't run if empty

Here's a subtle trap. If stdin is empty, xargs still runs the command once — with no arguments. On GNU systems, anyway.

# No .xyz files exist. What happens?
find . -name "*.xyz" | xargs echo "found:"
# found:
# ← ran echo with no arguments, printed the prefix alone

With rm this is harmless — rm with no arguments just prints a usage error. But some commands do destructive things when called with no arguments, or you might misinterpret the output as "something was found" when nothing was. The -r flag (GNU: --no-run-if-empty) suppresses the invocation entirely if there's no input.

find . -name "*.xyz" | xargs -r echo "found:"
# ← nothing. No output, no invocation.
Portability note

BSD xargs (including macOS) already behaves as if -r is the default — it won't run with empty input. GNU xargs requires -r explicitly. If you write scripts that need to run on both, add -r and it'll be quietly accepted on BSD even though it's a no-op there.

No command at all — the echo trick

If you run xargs with no command, it defaults to echo. This is the cheapest way to debug a pipeline — see exactly what xargs would pass as arguments without executing anything dangerous.

# What would xargs pass to rm?
find . -name "*.log" -print0 | xargs -0
# ./app/server.log ./app/debug.log ./logs/old.log

# Same thing, explicitly
find . -name "*.log" -print0 | xargs -0 echo
# ./app/server.log ./app/debug.log ./logs/old.log

Run this first. Then add the actual command once you've confirmed the input looks right.

Patterns Worth Knowing

Find + xargs (the canonical pattern)

The most common xargs pipeline in the wild:

# Search for a string across all Go files
find . -name "*.go" -print0 | xargs -0 grep -l "http.ListenAndServe"

# Delete all .pyc files
find . -name "*.pyc" -print0 | xargs -0 rm -f

# Count lines across all Markdown files
find . -name "*.md" -print0 | xargs -0 wc -l

Yes, find has -exec. It works. But -exec spawns a new process for every file, while xargs batches them. On a directory with ten thousand files, that's the difference between a second and a minute.

find -exec vs xargs

find -exec {} \; runs one command per file. find -exec {} + batches like xargs. The + variant is fine if you don't need -P, -I, or null-delimiter control. When you do, pipe to xargs.

Clipboard + xargs (the original prompt)

This is what kicked off this whole post. A shell function that slugifies clipboard text, piped to mkdir:

pbslugify () {
  pbpaste | tr '[:upper:]' '[:lower:]' | \
  tr -d "'" | tr '\n' '-' | tr ' ' '-' | \
  tr -d '.' | sed 's/-*$//'
}

# Create a directory from whatever's on your clipboard
pbslugify | xargs mkdir -p

Without xargs, this doesn't work. mkdir doesn't read from stdin. The pipe delivers the slug, but mkdir needs an argument. One word fixes it.

Git + xargs

# Checkout all files modified in the last commit
git diff --name-only HEAD~1 | xargs git checkout HEAD --

# Delete all merged branches
git branch --merged | grep -v '\*\|main\|master' | xargs git branch -d

# Stage all Go files that have changed
git diff --name-only | grep '\.go$' | xargs git add

Process management + xargs

# Kill all processes matching a pattern
pgrep -f "node.*dev-server" | xargs kill -9

# Send SIGHUP to reload all nginx workers
pgrep nginx | xargs kill -HUP

Bulk downloads

# Download a list of URLs, 5 at a time
cat urls.txt | xargs -P 5 -I {} curl -sO {}

# Wget with rate limiting
cat urls.txt | xargs -P 3 -I {} wget -q {}

sh -c — when one command isn't enough

There's a gap between "simple xargs" and "give up and write a while loop". That gap is sh -c. It lets you run a full shell expression — pipes, redirections, conditionals, variable manipulation — for each input item.

# Compress each file and log it
find . -name "*.log" -print0 | \
  xargs -0 -I {} sh -c 'gzip "$1" && echo "compressed: $1"' _ {}

# Rename .txt to .md
ls *.txt | xargs -I {} sh -c 'mv "$1" "${1%.txt}.md"' _ {}

# Process each file with a pipeline
find . -name "*.csv" -print0 | \
  xargs -0 -I {} sh -c 'head -1 "$1" | tr "," "\n" | wc -l' _ {}

The _ {} at the end looks cryptic until you understand what sh -c does with its trailing arguments. The first argument after the command string becomes $0 (the "script name" — convention is to use _ as a throwaway). The second becomes $1, which is the actual value you want. So {} gets replaced by xargs with the current input line, and inside the shell expression you reference it as $1.

Why $1 and not {} directly?

You could write sh -c 'gzip {}' and it would work — until the filename contains a space, a quote, or a semicolon. At that point the filename becomes part of the shell expression itself, which is a code injection bug. Using $1 keeps the filename as data, never as code. Always use the _ {} pattern.

When Not to Use xargs

There's a temptation to reach for xargs everywhere once you learn it. Resist.

If the command reads stdin, you don't need it. cat files.txt | xargs grep foo is just a slower version of grep foo $(cat files.txt) — or better, grep -r foo .. Don't add a tool to a pipeline that doesn't need one.

If you need complex logic per item — conditionals, error handling, variable assignment — and sh -c feels too cramped, a while read loop is more appropriate:

# When you need logic, use a loop
while IFS= read -r file; do
  if [ -f "$file" ]; then
    echo "Processing: $file"
    gzip "$file"
  else
    echo "Skipping: $file (not found)"
  fi
done < files.txt

If you need serious parallelism with job control, progress bars, retries, or output ordering — look at GNU Parallel. xargs -P is a Swiss Army knife. GNU Parallel is a workshop.

BSD vs GNU: The Portability Traps

If you develop on macOS and deploy to Linux — or vice versa — you're running two different implementations of xargs. They agree on the basics but diverge on the edges.

Behaviour GNU (Linux) BSD (macOS)
Empty input Runs command with no args Does nothing (implicit -r)
-I placeholder -I {} (explicit string required) -I {} (same syntax)
-r flag Suppresses empty-input run Accepted but no-op (already default)
-P parallel Supported Supported
Max arg length Uses ARG_MAX (~2MB) Uses ARG_MAX (~1MB)

The practical advice: always use -I {} with the explicit placeholder string on both platforms, add -r if you're writing scripts that might run on Linux, and use -print0 | xargs -0 everywhere — both implementations support it and it sidesteps the entire whitespace parsing question.

Quick Reference

Flag Purpose Example
-I {} Place argument at {} xargs -I {} cp {} /bak/{}
-0 Null-delimited input find -print0 | xargs -0
-n N Max N args per call xargs -n 1 gzip
-L N Max N lines per call xargs -L 1 ./process.sh
-P N Run N processes in parallel xargs -P 4 -n 1 gzip
-p Prompt before each run xargs -p rm
-t Print command before running xargs -t rm
-r Don't run if input is empty xargs -r rm
(no cmd) Debug: defaults to echo find -print0 | xargs -0

The Takeaway

The pipe connects stdout to stdin. That's it. It doesn't connect stdout to arguments, and it doesn't batch arguments to stay under the OS limit. xargs does both.

Command sits waiting for input? It reads stdin. You don't need xargs.

Command prints a usage error with no arguments? It reads arguments. You need xargs.

Working with thousands of files? You need xargs even if you could use command substitution, because command substitution will hit the wall and xargs won't.

That's the whole model. The rest is flags.