Module 3: Pipes, Redirection, and Composition

5. Pipes: connecting one command's output to another's input

Description

By the end of this lesson you will be able to build pipelines of several commands — two, three, four links — to answer questions about files you could never review by hand: how many error lines are in a log with tens of thousands of rows, how many files of a certain type exist across the whole system, which of a search's thousands of results are worth a closer look. You are going to build them with a concrete method — piece by piece, verifying every link — and you are going to know what to do when the next command in the chain refuses to read what you send it.

This is exactly what a developer or a systems administrator does when a server fails at three in the morning: nobody opens a two-million-line log file in a text editor to eyeball it. Three or four small commands get connected, each one doing exactly one thing well, until the question is reduced to a one-line answer. That is literally the Unix philosophy you talked about in this module's second lesson, put into practice with a single symbol.

Connection to the module: in the previous lesson you connected a command's output to a file>, >>, tee. Now you are going to connect a command's output directly to another command's input, with nothing touching disk in between. It is the same idea of "moving a stream to another destination" you already know; the destination, this time, is a live process instead of a still file.


The | operator: an assembly line, not a warehouse in between

Think about a factory assembly line. One machine cuts the fabric, the next one sews it, the next one adds buttons. No half-finished piece gets stored in a warehouse between machines — it goes straight from one conveyor belt to the next, the moment it leaves one and before the other needs it. If you wanted to store every half-finished piece in a box, label it, and have the next worker go fetch it from the box, the process would end up the same, just much slower and with an extra step nobody asked for.

The | operator (the vertical bar, above the Enter key on most keyboards) does exactly that with commands: it takes the standard output (descriptor 1) of the command on the left and connects it directly to the standard input (descriptor 0) of the command on the right. Both commands start almost at the same time, as independent processes, and the operating system pushes the text from one to the other as it gets produced — with nothing written to disk, no temporary file you have to delete afterward.

command1 | command2

Compare it to the alternative with no pipe, using what you already know from the previous lesson:

command1 > temp.txt
command2 < temp.txt
rm temp.txt

Three lines, an intermediate file that exists only to be deleted a second later, and a name (temp.txt) you have to invent and remember to clean up. The pipe replaces the three lines with a single one, and leaves no stray file behind.

Worked example

You have the same access.log you used in the grep lesson from the previous module:

INFO  2026-07-18 09:12:03 User 42 logged in
ERROR 2026-07-18 09:14:51 Payment gateway timeout for order 1188
WARN  2026-07-18 09:15:02 Retry scheduled for order 1188
ERROR 2026-07-18 09:15:10 Payment gateway timeout for order 1188
INFO  2026-07-18 09:20:44 User 17 logged in
ERROR 2026-07-18 09:22:19 Database connection refused

Today's question is more specific than "show me the errors": you want to know how many of those errors are, specifically, from the payment gateway. You build the pipeline one link at a time, verifying each one before adding the next.

Step 1 — the first command, alone, verified.

grep ERROR access.log

What to expect:

ERROR 2026-07-18 09:14:51 Payment gateway timeout for order 1188
ERROR 2026-07-18 09:15:10 Payment gateway timeout for order 1188
ERROR 2026-07-18 09:22:19 Database connection refused

Three lines. You check they are the ones you expected — yes, they are the three errors — before continuing.

Step 2 — you add the second link and check again.

grep ERROR access.log | grep Payment

What to expect:

ERROR 2026-07-18 09:14:51 Payment gateway timeout for order 1188
ERROR 2026-07-18 09:15:10 Payment gateway timeout for order 1188

The three lines from grep ERROR went in, one by one, to the second grep's standard input, which kept only the ones that also contain "Payment." You went from three to two. Exactly what you were after: the "Database connection refused" line had nothing to do with payments, and it got left out.

Step 3 — you add the final link, the one that answers the question with a single number.

grep ERROR access.log | grep Payment | wc -l

What to expect:

2

Three commands, each doing exactly one thing: filtering by "ERROR," filtering by "Payment," counting lines. Neither knows anything about the others beyond "I receive text, I process it, I hand out text." That mutual ignorance is why you can combine them in any sensible order and with any number of links.


Build it piece by piece: verify each link before continuing

What you just did above was not a coincidence specific to this example — it is the method you want to use any time a pipeline has more than one link, especially if any of them can delete, move, or overwrite something:

  1. Run the first command alone, with no | yet, and confirm its output is what you expected.
  2. Add just one more link and look at the result again. If something came out different from what you thought, the problem is in that last link you just added — you do not have to suspect the whole chain.
  3. Repeat until you reach the final command.
  4. Only then, if the final result is going to feed something destructive or irreversible (an rm, an mv, a script that modifies production), run the full pipeline one more time to confirm it before letting it loose.

Writing the pipeline all at once, end to end, and only then running it, is the most common way to lose half an hour figuring out which of the four commands has the bug. Writing one link, running, looking, adding the next, is slower the first time and much faster overall.


Immediate combinations with what you already know

You do not need new tools for a pipeline to be useful to you starting today. With what you already know from previous modules, these three combinations solve real questions all the time:

Counting how many entries a command produced, without counting them by eye:

ls -l /etc | wc -l

What to expect: a number — the count of lines ls -l /etc printed — instead of the full listing scrolling across the screen.

Calmly reviewing the results of a recursive search, when there are too many to fit on a single screen:

grep -r "TODO" src/ | less

What to expect: instead of hundreds of matches flooding the terminal all at once, less shows them to you one screen at a time, and you can move forward with the space bar or search within that result with /.

Keeping only the first results of a search that can take a while and produce thousands of lines:

find / -name "*.py" 2> /dev/null | head

What to expect: the first ten lines (head's default) of the list of .py files across the whole system, instead of waiting for find to finish walking every directory before you see anything on screen at all.

In all three cases the same thing happened: a command you already knew produced too much output to look at directly, and a single-link pipeline turned it into something manageable.


The classic mistake: commands that do not read standard input

Not every command reads its standard input. Some — rm, cp, mv among the most common — only look at their command-line arguments (technically called argv): the list of names you typed after the command, separated by spaces. For those commands, whatever reaches them through a pipe is invisible; they do not even look at it.

Imagine a reports/ directory with temporary files a script left behind:

ls reports/

What to expect:

monthly-summary.tmp
weekly-summary.tmp
sales-summary.tmp

You try to delete them by chaining find with rm, as if rm were going to "receive" the names through the pipe:

find reports/ -name "*.tmp" | rm

What to expect: a usage message or a "missing operand," and no file deleted. The exact text varies by system — something like rm: missing operand on Linux (GNU coreutils), or a usage: rm [-f | -i] ... file ... on macOS (BSD rm) — but the result is identical on both: rm complains that it is missing a required argument, because, indeed, none was passed to it. The text find sent through the pipe reached rm's standard input, but rm never looks at its standard input to decide what to delete: it only looks at argv, and argv came in empty.

The solution is xargs. It reads whatever reaches it through standard input, line by line, and turns it into command-line arguments for the command that follows it:

find reports/ -name "*.tmp" | xargs rm

What to expect: nothing on screen — rm received monthly-summary.tmp weekly-summary.tmp sales-summary.tmp as real arguments, one by one, and all three files vanished with no complaint.

The strange name that breaks everything: spaces in a file name

Now a coworker leaves a file with a name that does not follow the team's convention:

find reports/ -name "*.tmp"

What to expect:

reports/old report.tmp

You repeat the same pipeline you just learned:

find reports/ -name "*.tmp" | xargs rm

What to expect:

rm: reports/old: No such file or directory
rm: report.tmp: No such file or directory

Nothing got deleted, and the two "files" rm tried to delete do not even exist. By default, xargs separates arguments by whitespace — just like you would separate words typing on the command line. The one line it received, reports/old report.tmp, got split into two: reports/old and report.tmp. Neither one is the real file.

The exact solution: find can end every result with a null byte (\0) instead of a newline, with -print0. And xargs -0 tells xargs to use that same null byte as the separator, instead of spaces or newlines:

find reports/ -name "*.tmp" -print0 | xargs -0 rm

What to expect: no output on screen — and reports/old report.tmp, the whole file name, space included, is gone.

Why the null byte and not any other separator? Because it is the only byte a file name cannot contain on a Unix system (the slash / cannot appear inside a name either, but you already use that one to separate directories). A space, a tab, and even a newline can all be a legitimate part of a real file name — unusual, but legal. The null byte, not so. That is why -print0 together with xargs -0 is the only combination that guarantees no name, no matter how strange, gets cut where it should not.


How errors behave inside a pipeline

The | operator connects exactly one stream: the standard output (descriptor 1) on the left with the standard input (descriptor 0) on the right. It never touches any command's standard error (descriptor 2) anywhere in the chain. This has a consequence that surprises people the first time they see it:

grep ERROR missing-file.log | wc -l

What to expect:

grep: missing-file.log: No such file or directory
0

Two things happened, and neither has anything to do with the other. grep could not open a file that does not exist, and it sent that error straight to the screen through its descriptor 2 — the pipe never touched it, because the pipe only moves descriptor 1. At the same time, since grep produced no line through its descriptor 1 (it had nothing to search), wc -l received a completely empty input, and correctly counted zero lines in it. The 0 you see does not mean "there were no errors in the log" — it means "the log, which does not even exist, sent no line to wc." They are two independent results, mixed on the same screen by the mere coincidence that both commands write there by default.

Every command in a pipeline runs as a separate process, almost simultaneously, and each one ends with its own internal success-or-failure result — that, and how to use it to chain decisions, is what this module's last lesson covers. For now, keep the central idea: a pipeline never hides or redirects errors on its own. If you want them to travel through the pipeline too, you need to ask for it explicitly with 2>&1, as you saw in the previous lesson.


Common mistakes

1. Thinking | also combines errors, same as 2>&1 (conceptual). What happens: you run command1 | command2 expecting that, if command1 fails, that error gets filtered or "absorbed" inside the pipeline, and instead you keep seeing it on screen, mixed in with whatever command2 prints. Why: | connects a single stream — descriptor 1 with descriptor 0 — never descriptor 2. Combining the errors requires asking for it separately, with 2>&1 before the |. How to spot it: you see error messages on the terminal that "should not be there" because you assumed they were already filtered or redirected. How to fix it: if you really want errors to enter the pipeline, write command1 2>&1 | command2.

2. Piping into a command that does not read standard input. What happens: you write find . -name "*.bak" | rm (or the same pattern with cp or mv) expecting the final command to "receive" the names through the pipe, and instead it fails immediately with a usage message (usage: ...) without touching a single file. Why: rm, cp, and mv take file names as command-line arguments, not from standard input; whatever reaches them through the pipe is invisible to them. How to spot it: the command ends almost instantly with a "usage" or "missing operand" message instead of complaining about a specific file. How to fix it: insert xargs between the two commands (find . -name "*.bak" | xargs rm), which does read standard input and turns it into arguments.

3. Using xargs with no -0 when file names can have spaces or newlines. What happens: find directory -name "*.tmp" | xargs rm fails with "No such file or directory" for files that do exist, or — worse — deletes something you never meant to touch because one of the cut-up fragments accidentally matched another real name. Why: without -0, both find and xargs use whitespace and newlines as separators; if a real name contains any of those characters, it gets split at the wrong spot and stops being a single argument. How to spot it: before piping into a destructive command, check whether any name in the directory has spaces (ls -l already shows you this, though it is easy not to notice). How to fix it: always use find ... -print0 | xargs -0 ... when the final command deletes, moves, or modifies files — the null byte is the only character no file name can contain, so it never cuts where it should not.


Exercises

1. You have an orders.log file with thousands of lines starting with INFO, WARN, or ERROR. You want to know how many error lines mention the word timeout. You have never written this pipeline before. Describe, step by step, how you would build and verify it — not just what the final command is.

See solution

Step 1: grep ERROR orders.log alone, with nothing else, to confirm the lines that show up really are the errors you expect to see.

Step 2: add the second filter and look again: grep ERROR orders.log | grep timeout — you confirm the remaining subset makes sense (fewer lines than in step 1, all mentioning "timeout").

Step 3: add the final count: grep ERROR orders.log | grep timeout | wc -l — now you have the answer to the original question, a single number.

Why it works: verifying each link before adding the next means that, if something goes wrong, you know the problem is in the last command you added — you do not have to suspect the whole chain at once.

2. You run grep ERROR sales.log | wc -l in a directory where sales.log does not exist. What exactly shows up on screen, and why does the number wc -l prints not mean what it looks like it means at first glance?

See solution

Two independent things show up:

grep: sales.log: No such file or directory
0

grep's error message goes straight to the screen through its error descriptor (2) — the pipeline never touches it, because | only connects descriptor 1 to the next command's input. Since grep produced no line through its descriptor 1 (there was no file to read), wc -l received an empty input and correctly counted zero lines in it.

Why it works (or, in this case, why it misleads): the 0 does not mean "there are no errors in sales" — it means "no line reached wc," which is a completely different and much more serious situation: the file you wanted to check does not even exist.

3. This command fails and deletes nothing:

find backups/ -name "*.old" | rm

Why exactly does it fail, and how do you fix it?

See solution

It fails because rm does not read standard input at all: it only looks at the command-line arguments passed directly to it. The text find sends through the pipe reaches rm's standard input, but rm never checks it to decide what to delete — that is why it ends up complaining it is missing an argument, without touching any file.

The fix is inserting xargs, which does read standard input and turns it into arguments:

find backups/ -name "*.old" | xargs rm

Why it works: xargs is the translator between "text arriving through a pipe" and "command-line arguments," which is the only language rm understands.

4. An archive/ directory has, among others, a file named final report.old (with a space in the name). You run find archive/ -name "*.old" | xargs rm and see this error:

rm: archive/final: No such file or directory
rm: report.old: No such file or directory

What went wrong, and what is the correct pipeline to delete that file no matter what strange characters its name has?

See solution

It went wrong because, with no special flag, both find and xargs use whitespace and newlines as separators. The one line find produced, archive/final report.old, got split by xargs into two arguments because of the space it contains: archive/final and report.old — neither one is the file's real name.

The correct pipeline uses the null byte as the separator, with -print0 on find and -0 on xargs:

find archive/ -name "*.old" -print0 | xargs -0 rm

Why it works: the null byte is the only character a real file name cannot contain, so using it as the separator is the only way to guarantee a name with spaces, tabs, or even newlines reaches rm intact, with no cut in the wrong place.


Summary and next step

You can now connect one command's output directly to the next one's input using |, with no intermediate file involved. You know how to build that pipeline piece by piece — verifying each link before adding the next — instead of writing the whole thing and only then finding out where it failed. You recognize when a chain's final command does not read standard input and needs xargs to receive the names as arguments, and you know why -print0 together with xargs -0 is the only safe combination when file names can have spaces or unusual characters. And you understood that a pipeline never hides errors on its own: every command in the chain keeps sending its error descriptor straight to the screen, unless you explicitly ask otherwise.

Everything you connected in this lesson was pieces you already knew from previous modules: ls, find, grep, wc, head, less. The pipeline itself is just plumbing — it connects, but does not transform anything on its own. What really multiplies its power are tools designed specifically to live inside a pipeline: taking raw text on one side and handing out sorted, counted, or translated text on the other. That is exactly what is next: sort, uniq, cut, tr, and sed, the text toolkit that turns a two-command pipeline into a real analysis instrument.

Before moving on you should be able to:

  • explain in your own words exactly what the | operator connects, and what it does not connect (any command's error descriptor anywhere in the chain);
  • build a pipeline of at least three commands, adding and verifying one link at a time, without writing the whole thing in one go;
  • recognize when a command does not read standard input and needs xargs, and use -print0 together with xargs -0 when file names can have spaces or unusual characters.

Resources