Redirections and Pipelines
Every command you run is quietly connected to three data streams the moment it starts — one it reads from, two it writes to — whether or not you ever think about them. Redirection is just rewiring those connections: sending output to a file instead of your screen, feeding a file in as if it were typed input, or chaining commands together so one’s output becomes another’s input. This chapter covers all of that machinery, plus a pipeline exit-status gotcha that catches even experienced scripters off guard.
The Three Standard Streams
Every process starts with three open file descriptors, numbered:
| FD | Name | Default Destination |
|---|---|---|
0 | stdin (standard input) | Your keyboard |
1 | stdout (standard output) | Your terminal |
2 | stderr (standard error) | Your terminal |
Notice stdout and stderr both go to your terminal by default — which is why, without redirection, normal output and error messages appear mixed together on screen, even though they’re two genuinely separate streams under the hood. Splitting them apart, or sending either one somewhere else entirely, is what the rest of this chapter is about.
Redirecting Output: > and >>
> sends a command’s stdout to a file instead of the terminal, overwriting that file completely if it already exists:
echo "First line" > output.txt
cat output.txtFirst lineecho "This replaces everything" > output.txt
cat output.txtThis replaces everything>> does the same thing but appends instead of overwriting:
echo "Added line" >> output.txt
cat output.txtThis replaces everything
Added lineThe difference between these two is a common source of accidentally destroyed data — reaching for > when you meant >> silently erases whatever was there before, with no warning or confirmation.
Redirecting Input: <
< feeds a file’s contents to a command’s stdin, as though you’d typed them:
printf "line one\nline two\nline three\n" > lines.txt
cat < lines.txtline one
line two
line threeThis particular example doesn’t look different from cat lines.txt directly — cat accepts a filename argument as well — but the distinction matters for commands and scripts that only read from stdin and have no filename-argument option at all. Redirecting < gives you a way to feed such a command file-based input regardless.
Redirecting Standard Error: 2> and 2>>
Since stderr is file descriptor 2, redirecting it uses the same operators with 2 prefixed:
ls existing-report.txt does-not-exist.txtls: cannot access 'does-not-exist.txt': No such file or directory
existing-report.txtBoth lines appear together on screen because stdout and stderr both default to the terminal — but they’re genuinely separate streams, and you can prove it by redirecting only one:
ls existing-report.txt does-not-exist.txt 2> errors.txtexisting-report.txtcat errors.txtls: cannot access 'does-not-exist.txt': No such file or directoryThe normal filename listing still printed to the terminal — only the error line was diverted into errors.txt. 2>> appends to an existing error log the same way >> does for stdout.
Merging Streams: 2>&1
Sometimes you want both stdout and stderr going to the same place — a single combined log file, for example. 2>&1 means “make file descriptor 2 point wherever file descriptor 1 currently points”:
ls existing-report.txt does-not-exist.txt > combined.log 2>&1
cat combined.logls: cannot access 'does-not-exist.txt': No such file or directory
existing-report.txtBoth the normal output and the error message ended up in the same file, in the order the command actually produced them.
Warning
Order matters here, and it’s easy to get backwards. > combined.log 2>&1 works because stdout is redirected to the file first, and stderr is then pointed at wherever stdout currently goes — the file. Written the other way around, 2>&1 > combined.log, stderr gets pointed at wherever stdout goes at that moment — which is still the terminal, since the > redirection hasn’t happened yet — and only then is stdout redirected to the file. The result: stdout goes to the file, but stderr still prints to your terminal, completely defeating the intent. This exact mistake is covered in depth, with a runnable comparison, in this chapter’s Shell-Safety Considerations section below.
Redirecting Stdout To Stderr: >&2
There’s a reverse case worth knowing too: sending something that would normally go to stdout over to stderr instead, using >&2 (short for 1>&2):
echo "This is a diagnostic message" >&2Why deliberately do this? Stdout is the stream callers actually care about — piping into another command, redirecting to a file, or capturing the output for later use. A script’s diagnostic or progress messages don’t belong mixed into that stream; if they are, anything consuming the script’s real output ends up parsing your status noise right along with it. Sending diagnostics to stderr keeps stdout clean for whatever the script is actually meant to produce.
Try it yourself:
cat > status-and-result.sh << 'EOF'
#!/usr/bin/env bash
echo "Starting process..." >&2
echo "42"
EOFchmod +x status-and-result.sh
./status-and-result.sh > result.txtSee what gets in the file:
cat result.txt42The "Starting process..." line never touched result.txt — it went to the terminal via stderr, while only the real result, 42, landed in the file.
Discarding Output: /dev/null
/dev/null is a special file that discards anything written to it and always reads as empty. Redirect output there to silence it entirely:
ls existing-report.txt does-not-exist.txt > /dev/null 2>&1
echo "Exit status: $?"Exit status: 1Nothing printed at all — both streams were discarded — but the command still ran and still produced a real exit status you can check with $?. This pattern, > /dev/null 2>&1, is extremely common for scripts that need to run a command purely for its side effects or exit status, with no interest in its output either way.
Pipelines
A pipe (|) connects one command’s stdout directly to the next command’s stdin, without ever touching a file:
printf "banana\napple\ncherry\n" | sortapple
banana
cherryChain more than two commands together the same way:
printf "banana\napple\ncherry\napple\n" | sort | uniq | wc -l3Each stage runs as its own separate process, connected to the next by a direct pipe rather than an intermediate file — faster, and without leaving temporary files behind. One consequence worth knowing now: because each stage is a separate process, changes a stage makes to shell state — variables it sets, for instance — don’t persist once the pipeline finishes. The mechanics of exactly why fall under subshells, covered fully later in the course.
tee — Splitting A Stream
tee reads from stdin and writes the same data to both a file and stdout simultaneously, letting you save output partway through a pipeline without breaking the chain:
printf "banana\napple\ncherry\n" | sort | tee sorted.txt | tail -n 1cherrycat sorted.txtapple
banana
cherryThe full sorted list landed in sorted.txt, while the pipeline continued on to tail -n 1, which still received the complete sorted output and printed just the last line. tee -a appends instead of overwriting, and tee can take multiple filenames to write the same stream to several files at once.
Pipeline Exit Status & PIPESTATUS
Checking $? after a pipeline only tells you about the last command in it — every earlier stage’s success or failure is invisible by default:
false | true
echo "Exit status: $?"Exit status: 0false (a builtin that always fails) ran first and failed, but because true — the last stage — succeeded, $? reports success for the whole pipeline. A failure in an earlier stage was silently swallowed.
Bash tracks every stage’s individual exit status in a special array variable, PIPESTATUS, immediately after a pipeline runs:
false | true
echo "${PIPESTATUS[0]} ${PIPESTATUS[1]}"1 0PIPESTATUS is indexed starting at 0 for the first command in the pipeline, 1 for the second, and so on — you’ll cover array syntax in full later in this course, but this much is enough to read individual stage results right now: ${PIPESTATUS[0]} is the first command’s exit status, ${PIPESTATUS[1]} the second’s, and so on down the pipeline.
Note
Checking PIPESTATUS immediately is important — it reflects only the most recently completed pipeline, and gets overwritten the instant you run another command. A later chapter covers a shell option that can change Bash’s default pipeline exit-status behavior entirely, but PIPESTATUS works regardless of that setting and is worth knowing on its own.
Best Practices
- Default to
2>&1placed immediately after your main redirection (> file 2>&1, not the reverse) — treat the correct order as a fixed pattern to reach for, not something to reconstruct from first principles each time. - Use
>>deliberately, not>out of habit, for anything that logs or accumulates over multiple runs — a script that overwrites its own log file every time it runs isn’t logging anything useful. - Check
PIPESTATUSwhenever a pipeline’s correctness genuinely depends on every stage succeeding, not just the last one — especially for pipelines involving filtering or processing stages where a silent early failure could produce misleadingly “successful” output. - Reach for
teewhen debugging a pipeline you’re not sure is behaving correctly — insertingtee debug.txtbetween stages lets you inspect exactly what’s flowing through at that point, without altering the pipeline’s actual behavior.
Shell-Safety Considerations
The 2>&1 ordering issue mentioned above is common enough, and easy enough to get backwards under pressure, that it’s worth seeing both versions run side by side.
Naive (broken) version — stderr merged before stdout is redirected:
ls existing-report.txt does-not-exist.txt 2>&1 > combined.logls: cannot access 'does-not-exist.txt': No such file or directoryThat error message printed straight to your terminal — it was never captured. Check the file to confirm:
cat combined.logexisting-report.txtOnly the normal output made it into combined.log. The error is gone from the file entirely, sitting only in whatever terminal happened to be watching when the command ran — exactly the kind of gap that turns into a real problem when this pattern is used inside an unattended script whose “log file” was supposed to capture everything.
Corrected version — stdout redirected first, stderr merged second:
ls existing-report.txt does-not-exist.txt > combined.log 2>&1
cat combined.logls: cannot access 'does-not-exist.txt': No such file or directory
existing-report.txtBoth lines are captured this time. The rule to hold onto: read 2>&1 as “point stderr at wherever stdout currently points” — which means whatever redirects stdout has to come first, or 2>&1 is duplicating the wrong target.
Tip
Bash also offers a shorthand for this exact correct pattern: command &> combined.log redirects both streams to the same file in one step, sidestepping the ordering question entirely. It’s worth knowing the > file 2>&1 form regardless, since &> isn’t universally supported outside Bash and you’ll see the long form constantly in other people’s scripts.
Hands-On: Redirection, Pipelines, And Catching A Hidden Pipeline Failure
1. Set up a working directory.
mkdir -p ~/shell-course/ch5
cd ~/shell-course/ch52. Practice > vs. >>.
echo "First run" > log.txt
cat log.txt
echo "Second run" > log.txt
cat log.txt
echo "Third run" >> log.txt
cat log.txtConfirm the second echo completely replaced the first, while the third one added on rather than replacing.
3. Separate stdout and stderr from a command that produces both.
touch existing-report.txt
ls existing-report.txt does-not-exist.txt 1> stdout.txt 2> stderr.txt
cat stdout.txt
cat stderr.txt4. Reproduce the 2>&1 ordering bug, then fix it.
ls existing-report.txt does-not-exist.txt 2>&1 > combined-broken.log
cat combined-broken.logexisting-report.txtls existing-report.txt does-not-exist.txt > combined-fixed.log 2>&1
cat combined-fixed.logls: cannot access 'does-not-exist.txt': No such file or directory
existing-report.txt5. Build a small pipeline and split it with tee.
printf "banana\napple\ncherry\napple\n" | sort | uniq | tee unique-sorted.txt | wc -l
cat unique-sorted.txt6. Catch a hidden pipeline failure with PIPESTATUS.
false | true
echo "Pipeline \$? : $?"
echo "First stage status : ${PIPESTATUS[0]}"
echo "Second stage status: ${PIPESTATUS[1]}"Pipeline $? : 0
First stage status : 1
Second stage status: 07. Clean up.
cd ~
rm -rf ~/shell-course/ch5You’ve now redirected each stream independently, merged them correctly (and seen exactly how getting the order backwards silently drops error output), built and split a pipeline with tee, and used PIPESTATUS to catch a failure that $? alone would have hidden completely. The next chapter covers here documents and here strings — feeding multi-line input to a command without a separate file at all.