Lesson 2 of 14
Pipes, Redirection, and Exit Codes
Every command reads from an input stream and writes to two output streams. The shell can connect those streams to files, to other commands, or to nothing. This lesson covers how to do that and how to read the numeric result a command leaves behind. All outputs below were produced with GNU bash 5.2 on Linux.
| Feature | Purpose |
|---|---|
>, >> |
Send standard output to a file, replacing it or appending to it |
2>, 2>&1, &> |
Redirect standard error, or merge it with standard output |
< |
Read standard input from a file |
/dev/null |
Discard anything written to it |
| |
Connect the output of one command to the input of the next |
tee |
Copy a stream to a file and pass it on |
$? |
Exit code of the last command |
&&, ||, ; |
Run commands depending on the previous result |
<<, <<< |
Feed text written in the script to a command |
$(...) |
Replace a command with its output |
set -o pipefail |
Make a pipeline fail when any stage fails |
Example 1: stdin, stdout, and stderr
Every process starts with three open streams, each identified by a number called a file descriptor.
| Number | Name | Default connection | Purpose |
|---|---|---|---|
| 0 | stdin (standard input) | keyboard | Data the command reads |
| 1 | stdout (standard output) | terminal | Normal results |
| 2 | stderr (standard error) | terminal | Error and diagnostic messages |
A terminal shows stdout and stderr in the same place, so they look like one stream. The next command lists one file that exists and one that does not.
mkdir -p demo && cd demo
ls /etc/hostname /nonexistent
echo ---
ls /etc/hostname /nonexistent 2>/dev/nullls: cannot access '/nonexistent': No such file or directory
/etc/hostname
---
/etc/hostnameThe first command printed a result and an error. The second added 2>/dev/null, which sends file descriptor 2 to a place that discards data. Only the stdout line remains. Two streams exist so that results can be saved or piped onward without error messages mixed in, and so that errors stay visible when results are redirected.
The error line appears before the result in the first output. The streams are written independently, and ls reports the failure before it prints the listing.
Example 2: > and >> write stdout to a file
The operator > sends stdout to a file. The shell creates the file if it does not exist and empties it first if it does. The operator >> appends to the end instead.
echo "first" > log.txt
echo "second" > log.txt
cat log.txt
echo "third" >> log.txt
cat log.txtsecond
second
thirdThe second > replaced first. cat printed second, which is the whole file. The >> operator then added third as a new last line, and the second cat printed both lines. The first line of the output is the first cat, and the last two lines are the second cat.
The shell opens and empties the file before the command starts. For that reason sort data.txt > data.txt leaves an empty file: the file is emptied before sort reads it. Write to a different file and rename it.
Overwriting can be blocked with the noclobber option. The operator >| overrides it for one command.
set -o noclobber
echo x > log.txt
echo "status $?"
echo y >| log.txt
cat log.txtbash: log.txt: cannot overwrite existing file
status 1
yWith noclobber set, > refuses to replace an existing file and the command fails with status 1. The message prefix is the shell name when typed at a prompt. Inside a script it is the script name and line number. The >| form replaced the file anyway.
Example 3: 2>, 2>&1, and &> redirect stderr
The operator 2> sends file descriptor 2 to a file. It combines with > so that each stream goes to its own file.
ls /etc/hostname /nonexistent > out.txt 2> err.txt
echo "out.txt:"; cat out.txt; echo "err.txt:"; cat err.txtout.txt:
/etc/hostname
err.txt:
ls: cannot access '/nonexistent': No such file or directoryNothing appeared on the terminal while ls ran, because both streams were redirected. out.txt holds the result and err.txt holds the error.
To collect both streams in one file, use 2>&1. It reads as "make descriptor 2 point to wherever descriptor 1 points". The operator &> is a bash shorthand for the same effect.
ls /etc/hostname /nonexistent > both.txt 2>&1
cat both.txt
echo ---
ls /etc/hostname /nonexistent &> all.txt
cat all.txtls: cannot access '/nonexistent': No such file or directory
/etc/hostname
---
ls: cannot access '/nonexistent': No such file or directory
/etc/hostnameBoth forms produce the same file. The operator &> works in bash and zsh but not in POSIX sh, so scripts that must run under sh use > file 2>&1.
Order matters, because the shell processes redirections from left to right. In > both.txt 2>&1, stdout is sent to the file first, and then stderr is pointed at the same place. Reversing the order gives a different result.
ls /etc/hostname /nonexistent 2>&1 > only-out.txt
echo "only-out.txt:"; cat only-out.txtls: cannot access '/nonexistent': No such file or directory
only-out.txt:
/etc/hostnameHere 2>&1 ran while descriptor 1 still pointed at the terminal, so stderr was copied to the terminal. Only afterward was stdout moved to the file. The error appeared on screen and only the result reached the file.
Example 4: < reads stdin from a file
The operator < connects a file to the command's stdin. Many commands accept a file name as an argument and also read stdin when none is given. sort orders lines alphabetically, and wc -l counts lines.
printf 'pear\napple\nfig\n' > fruit.txt
sort < fruit.txt
echo ---
wc -l fruit.txt
wc -l < fruit.txt
echo ---
sort < fruit.txt > sorted.txt
cat sorted.txtapple
fig
pear
---
3 fruit.txt
3
---
apple
fig
pearsort < fruit.txt printed the three lines in order. The two wc -l calls show a difference: with a file argument, wc prints the name next to the count. With <, wc receives an anonymous stream and has no name to print, so the output is only the number. That form is useful when the number is stored in a variable.
The last command combines both directions. The file fruit.txt is the input and sorted.txt is the output. Unlike the sort data.txt > data.txt mistake above, the two files differ, so the input is intact when sort reads it.
Example 5: /dev/null discards output
The file /dev/null accepts any write and throws the data away. Reading from it returns end-of-file immediately. Redirecting to it silences a stream.
ls /nonexistent 2>/dev/null; echo "status $?"
echo ---
grep -r "root" /etc/passwd >/dev/null && echo "found"
echo ---
echo "hello" > /dev/null
cat /dev/null | wc -c
head -c 4 /dev/zero | od -An -tx1status 2
---
found
---
0
00 00 00 00In the first command the error text disappeared, but the status was still 2. Redirection changes where the text goes and does not change whether the command failed.
In the second, >/dev/null hid the matching line of grep while && still ran echo because grep succeeded. That pattern checks for a match without printing it. For this purpose grep -q is the dedicated option, covered in Finding Files and Searching Text.
The third part shows that /dev/null holds nothing: wc -c counted zero bytes. The neighbor device /dev/zero produces an endless stream of zero bytes. head -c 4 took four of them and od (octal dump, here set to hexadecimal with -tx1) displayed them. -An removes the offset column.
To silence everything a command prints, combine both streams: command > /dev/null 2>&1 or command &> /dev/null.
Example 6: | connects commands
A pipe sends the stdout of the left command to the stdin of the right command. Both commands run at the same time, and data flows between them without an intermediate file. The pipeline below counts how often each line occurs. uniq -c collapses adjacent duplicate lines and prefixes each with a count. Adjacent is the key word, so sort runs first to bring equal lines together.
printf 'b 2\na 1\nb 2\nc 3\na 1\nb 2\n' > data.txt
cat data.txt | sort | uniq -c | sort -rn 3 b 2
2 a 1
1 c 3The stages work as follows:
cat data.txtprints the six lines.sortorders them so equal lines are neighbors.uniq -cproduces one line per distinct value with its count.sort -rnorders numerically (-n) in reverse (-r), largest count first.
A pipe carries only stdout. Stderr still goes to the terminal unless it is redirected too. The next commands show the effect by counting lines of output.
ls /etc/hostname /nonexistent | wc -l
echo ---
ls /etc/hostname /nonexistent 2>&1 | wc -l
echo ---
ls /etc/hostname /nonexistent |& wc -lls: cannot access '/nonexistent': No such file or directory
1
---
2
---
2In the first command the error message skipped the pipe and reached the terminal, and wc counted only the one result line. With 2>&1 placed before the pipe, stderr joined stdout and wc counted two lines. The bash shorthand |& means 2>&1 |.
A pipe also stops early when the reader stops reading. The command yes prints the letter y forever.
yes | head -n 3y
y
yAfter head read three lines it exited. The next write by yes hit a closed pipe, the operating system sent it a signal called SIGPIPE, and yes ended. This is why pipelines over very large or endless inputs finish quickly when the last stage needs only part of the data.
Example 7: tee copies a stream to a file
tee reads stdin, writes it to the files named as arguments, and also writes it to stdout. It is a T-junction in the pipeline: the data continues, and a copy is saved.
printf 'b\na\nc\n' > in.txt
sort in.txt | tee sorted.txt | head -n 2
echo ---
cat sorted.txta
b
---
a
b
cThe terminal shows the first two lines because head -n 2 ended the pipeline's visible output. sorted.txt holds all three lines, because tee copied the full sorted stream before head took its part.
tee accepts several files. The -a option appends instead of replacing, like >>.
echo one | tee a.txt b.txt >/dev/null
cat a.txt b.txt
echo ---
echo two | tee -a a.txt
cat a.txtone
one
---
two
one
twoThe first command wrote one to both files and discarded the stdout copy with >/dev/null. The second command appended two to a.txt and still printed it, because the stdout copy was not redirected.
| Option | Effect |
|---|---|
| (none) | Replace each file |
-a |
Append to each file |
-i |
Ignore the interrupt signal (Ctrl-C) |
A common use of tee is writing to a file that needs elevated privileges. In echo text > /etc/file the shell opens the file, and the shell does not have the privilege. In echo text | sudo tee /etc/file, the tee process opens the file and runs with the privilege. This is stated from the behavior of the shell and sudo. It was not run here.
On macOS the BSD tee supports -a and -i as well, so these examples work unchanged.
Example 8: exit codes and $?
Every command ends with an exit code, a number from 0 to 255. Zero means success. Any other value means failure, and the value says which kind. The shell stores the code of the last command in the variable $?.
true; echo "true: $?"
false; echo "false: $?"
ls /nonexistent 2>/dev/null; echo "ls missing: $?"
grep -q zzz /etc/hostname; echo "grep no match: $?"
grep -q "" /etc/hostname; echo "grep match: $?"
nosuchcmd 2>/dev/null; echo "not found: $?"
touch noexec.sh; ./noexec.sh 2>/dev/null; echo "not executable: $?"
bash -c 'exit 42'; echo "exit 42: $?"
sleep 5 & kill -9 $! ; wait $! 2>/dev/null; echo "killed: $?"
false; echo "first: $?"; echo "second: $?"true: 0
false: 1
ls missing: 2
grep no match: 1
grep match: 0
not found: 127
not executable: 126
exit 42: 42
killed: 137
first: 1
second: 0Each line shows a convention:
| Code | Meaning | Source in the example |
|---|---|---|
| 0 | Success | true, a matching grep |
| 1 | General failure | false, grep without a match |
| 2 | Misuse or serious error (varies by command) | ls on a missing path |
| 126 | File found but not executable | ./noexec.sh has no execute permission |
| 127 | Command not found | nosuchcmd |
| 128 + n | Killed by signal n | kill -9 gives 128 + 9 = 137 |
For grep, status 1 means "no match" and not an error. Scripts that treat any nonzero value as an error need to account for this.
The last two lines show that $? is overwritten by every command. echo "first: $?" is itself a command that succeeded, so the next $? is 0. Copy the value into a variable if it is needed later, for example rc=$?.
The if statement tests an exit code directly, without $?. The condition is true when the command returns 0.
if grep -q root /etc/passwd; then echo "if saw success"; fiif saw successA script can set its own code with exit N, and a function can set one with return N. A script that ends without exit returns the code of its last command.
Example 9: &&, ||, and ;
These operators chain commands using the exit code of the previous one.
| Operator | Next command runs when |
|---|---|
; |
Always, after the previous command ends |
&& |
The previous command returned 0 |
|| |
The previous command returned nonzero |
The next commands use mkdir, which fails if the directory already exists.
mkdir build && echo "created"
mkdir build 2>/dev/null && echo "created again"
mkdir build 2>/dev/null || echo "already exists"
echo ---
echo a; false; echo b
echo ---
false && echo "not printed"; echo "after &&"
true || echo "not printed"; echo "after ||"created
already exists
---
a
b
---
after &&
after ||The first mkdir succeeded, so && ran echo "created". The second failed, so && skipped its echo, and nothing printed. The third failed, so || ran its echo. The semicolon version ran echo b even though false failed. Statements after a ; do not depend on earlier ones.
&& and || have equal precedence and group from left to right. The next lines show how a chain is read.
true || echo X && echo Y
false && echo X || echo Y
true && false || echo "fallback"
test -f missing.txt || echo "missing.txt not found"Y
Y
fallback
missing.txt not foundIn the first line, true || echo X skipped echo X and left the status at 0, so && echo Y ran. In the second, false && echo X skipped echo X and left the status at 1, so || echo Y ran. The third line shows the risk of the A && B || C pattern as a replacement for if-else: C runs when A fails and also when B fails. Use if when both branches matter.
The last line shows the guard pattern. test -f checks that a regular file exists and returns 1 when it does not. The || branch then reports it.
Example 10: here-documents and here-strings
A here-document supplies several lines of text to stdin from inside the script. The text starts after <<WORD and ends at a line that contains only WORD. The shell expands variables and $(...) in the text unless the delimiter is quoted.
cat <<EOF
Sum is $((2+3))
EOF
echo ---
cat <<'EOF'
Literal: $HOME and $((2+3))
EOFSum is 5
---
Literal: $HOME and $((2+3))With a bare EOF, the shell evaluated the arithmetic $((2+3)) and printed 5. With 'EOF' in quotes, the text arrived unchanged. Quote the delimiter when the text contains $ characters that must stay literal, such as a generated shell script.
The form <<- strips leading tab characters from each line and from the delimiter. That allows indentation inside if blocks and functions. The indentation below uses real tab characters.
if true; then
cat <<-EOF
indented with tab
EOF
fi
echo ---
cat > config.ini <<EOF
[server]
port=8080
EOF
cat config.iniindented with tab
---
[server]
port=8080The tabs were removed before cat saw the text. Spaces are not stripped, only tabs. The second command combines a here-document with > to write a multi-line file, which is the usual way to create a configuration file from a script.
A here-string, written <<<, supplies a single string as stdin and adds a trailing newline. It replaces echo "text" | command without starting a separate process for echo.
grep -c o <<< "foo boo"
read -r a b <<< "x y"; echo "$b $a"
wc -w <<< "one two three"
word=hello; tr a-z A-Z <<< "$word"1
y x
3
HELLOgrep -c o counted one matching line, since the string is a single line. read -r a b split the string on whitespace and assigned the words to two variables. wc -w counted three words. tr a-z A-Z translated lowercase letters to uppercase. A here-string and a here-document are bash and zsh features and are not part of POSIX sh, which supports only <<.
Because read ran in the current shell with a here-string, the variables stayed set. With echo "x y" | read a b the read runs in a subshell, a child copy of the shell, and the variables vanish when the pipeline ends.
Example 11: command substitution $(...)
The form $(command) runs the command and replaces itself with the command's stdout. The shell strips trailing newlines from the result. The substitution can sit inside a string, an argument, or a variable assignment.
echo "hostname lines: $(wc -l < /etc/hostname)"
n=$(printf 'a\nb\nc\n' | wc -l); echo "n=$n"
touch a.txt b.txt c.txt
echo "count: $(ls *.txt | wc -l)"
mkdir "backup-$(echo 2026-10-03)"; ls -d backup-*hostname lines: 1
n=3
count: 3
backup-2026-10-03Each $(...) was replaced by its output before the outer command ran. The directory name backup-2026-10-03 shows the common use: building a name from a command such as date +%F. A fixed text stands in for date here so the output stays the same on every run.
Substitutions nest without escaping, which the older backtick form `command` does not allow cleanly.
echo "outer $(echo inner $(echo deepest))"
v=$(false); echo "status after assignment: $?"
x=$(printf 'a\n\n\n'); printf '[%s]\n' "$x"
echo `echo backtick`outer inner deepest
status after assignment: 1
[a]
backtickThe inner substitution ran first and the middle one second. In v=$(false), the exit code of an assignment is the exit code of the substitution, so $? is 1. The third command shows that the three newlines after a were stripped. The backtick form worked but is harder to nest and to read, so $(...) is the standard form.
A related form is process substitution, <(command). It presents a command's output as a temporary file name, which suits commands that need file arguments.
diff <(printf 'a\nb\n') <(printf 'a\nc\n')2c2
< b
---
> cdiff compared two streams as if they were files. Line 2 changed from b to c. Process substitution is a bash, zsh, and ksh feature and is not available in POSIX sh.
Quote "$(...)" when the result may hold spaces, or the shell splits it into several words. Word splitting is covered in Variables, Quoting, and the Environment.
Example 12: set -o pipefail
A pipeline normally returns the exit code of its last command only. A failure in an earlier stage is lost.
false | true; echo "no pipefail: $?"
set -o pipefail
false | true; echo "pipefail: $?"
echo ---
ls /nonexistent 2>/dev/null | wc -l; echo "status ${PIPESTATUS[0]} ${PIPESTATUS[1]}"no pipefail: 0
pipefail: 1
---
0
status 2 0Without pipefail, false | true reports 0, because true was the last stage. With set -o pipefail, the pipeline returns the code of the rightmost command that failed, here 1. The array PIPESTATUS is a bash feature that holds the exit code of every stage of the last pipeline. In the third command, PIPESTATUS shows that ls returned 2 and wc returned 0. Without pipefail, the pipeline's own code would be the 0 of wc.
When several stages fail, the rightmost failing code is returned.
bash -c 'set -o pipefail; (exit 3) | (exit 4) | true; echo "rightmost failure: $?"'
bash -c 'set -o pipefail; (exit 3) | true | true; echo "single failure: $?"'
bash -c 'set -e; false; echo "not reached"'; echo "set -e status: $?"
bash -c 'set -eu -o pipefail; false | cat; echo "not reached"'; echo "strict status: $?"rightmost failure: 4
single failure: 3
set -e status: 1
strict status: 1The first two lines show the rule for the returned code: the last nonzero code from the right. The (exit 3) form runs exit in a subshell so the test shell does not end.
The option set -e ends a script at the first command that fails. The option set -u ends it when an unset variable is used. The combination set -euo pipefail at the top of a script is a common strict-mode header. The three options address different gaps: -e for failed commands, -u for unset variables, and pipefail for failures hidden inside pipelines. set -e has exceptions: commands tested by if, while, &&, or || do not stop the script when they fail.
The effect of pipefail on grep needs attention. When grep -q finds a match it exits early, and the earlier stage can then die from SIGPIPE and return 141. Under pipefail that turns a successful search into a failed pipeline. For that case, capture the output first or test the stages separately.
On macOS the default shell is zsh. It supports pipefail through setopt pipefail and set -o pipefail, and it uses the array pipestatus in lowercase in place of PIPESTATUS. This comes from the zsh documentation. It was not run here.
Interview points
- Streams 0, 1, and 2 are stdin, stdout, and stderr. A pipe carries only stdout, so errors need
2>&1or|&to travel through it. - Redirections are processed from left to right.
> file 2>&1sends both streams to the file, while2>&1 > filesends stderr to the terminal and stdout to the file. sort file > fileempties the file beforesortreads it, because the shell opens the output first. Write to a temporary file and rename it, or usesort -o file file.>replaces a file,>>appends, andset -o noclobberblocks accidental replacement until>|is used.- Exit code 0 is success. Codes 126 and 127 mean not executable and not found. A code of 128 + n means the process died from signal n.
grepreturns 1 for "no match". $?reflects only the last command. Save it withrc=$?before running another command.A && B || Cis not an if-else.Calso runs whenBfails.- A pipeline returns the status of its last stage unless
set -o pipefailis on.PIPESTATUSholds every stage's status in bash. teewrites to both a file and stdout.sudo teeis the way to write a privileged file from a pipeline, because the redirection operator is handled by the unprivileged shell.- A quoted here-document delimiter (
<<'EOF') disables expansion. An unquoted one expands variables and$(...). $(...)strips trailing newlines and nests cleanly. Quote it to prevent word splitting.
Summary
- Each process has stdin (0), stdout (1), and stderr (2).
>truncates and writes,>>appends,<reads,2>redirects stderr, and&>redirects both in bash.2>&1points stderr at the current target of stdout, and the order of redirections changes the result./dev/nulldiscards writes and returns end-of-file on reads.|connects stdout of one command to stdin of the next, and stages run at the same time.- A writer to a closed pipe receives SIGPIPE, which lets
yes | head -n 3end. teecopies a stream to files and to stdout.-aappends.- Exit code 0 means success. Nonzero values mean failure, with 126, 127, and 128 + n having fixed meanings.
&&runs the next command after success,||after failure, and;always.<<WORDsupplies a here-document,<<-strips leading tabs, and<<<supplies a here-string.$(...)replaces a command with its output and<(...)presents it as a file name.set -o pipefailmakes a pipeline return the rightmost nonzero status.set -euo pipefailis a strict-mode header.
