Guide contents

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.

Terminal
mkdir -p demo && cd demo
ls /etc/hostname /nonexistent
echo ---
ls /etc/hostname /nonexistent 2>/dev/null
Output
ls: cannot access '/nonexistent': No such file or directory
/etc/hostname
---
/etc/hostname

The 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.

Terminal
echo "first" > log.txt
echo "second" > log.txt
cat log.txt
echo "third" >> log.txt
cat log.txt
Output
second
second
third

The 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.

Terminal
set -o noclobber
echo x > log.txt
echo "status $?"
echo y >| log.txt
cat log.txt
Output
bash: log.txt: cannot overwrite existing file
status 1
y

With 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.

Terminal
ls /etc/hostname /nonexistent > out.txt 2> err.txt
echo "out.txt:"; cat out.txt; echo "err.txt:"; cat err.txt
Output
out.txt:
/etc/hostname
err.txt:
ls: cannot access '/nonexistent': No such file or directory

Nothing 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.

Terminal
ls /etc/hostname /nonexistent > both.txt 2>&1
cat both.txt
echo ---
ls /etc/hostname /nonexistent &> all.txt
cat all.txt
Output
ls: cannot access '/nonexistent': No such file or directory
/etc/hostname
---
ls: cannot access '/nonexistent': No such file or directory
/etc/hostname

Both 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.

Terminal
ls /etc/hostname /nonexistent 2>&1 > only-out.txt
echo "only-out.txt:"; cat only-out.txt
Output
ls: cannot access '/nonexistent': No such file or directory
only-out.txt:
/etc/hostname

Here 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.

Terminal
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.txt
Output
apple
fig
pear
---
3 fruit.txt
3
---
apple
fig
pear

sort < 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.

Terminal
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 -tx1
Output
status 2
---
found
---
0
 00 00 00 00

In 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.

Terminal
printf 'b 2\na 1\nb 2\nc 3\na 1\nb 2\n' > data.txt
cat data.txt | sort | uniq -c | sort -rn
Output
      3 b 2
      2 a 1
      1 c 3

The stages work as follows:

  1. cat data.txt prints the six lines.
  2. sort orders them so equal lines are neighbors.
  3. uniq -c produces one line per distinct value with its count.
  4. sort -rn orders 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.

Terminal
ls /etc/hostname /nonexistent | wc -l
echo ---
ls /etc/hostname /nonexistent 2>&1 | wc -l
echo ---
ls /etc/hostname /nonexistent |& wc -l
Output
ls: cannot access '/nonexistent': No such file or directory
1
---
2
---
2

In 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.

Terminal
yes | head -n 3
Output
y
y
y

After 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.

Terminal
printf 'b\na\nc\n' > in.txt
sort in.txt | tee sorted.txt | head -n 2
echo ---
cat sorted.txt
Output
a
b
---
a
b
c

The 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 >>.

Terminal
echo one | tee a.txt b.txt >/dev/null
cat a.txt b.txt
echo ---
echo two | tee -a a.txt
cat a.txt
Output
one
one
---
two
one
two

The 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 $?.

Terminal
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: $?"
Output
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: 0

Each 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.

Terminal
if grep -q root /etc/passwd; then echo "if saw success"; fi
Output
if saw success

A 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.

Terminal
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 ||"
Output
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.

Terminal
true || echo X && echo Y
false && echo X || echo Y
true && false || echo "fallback"
test -f missing.txt || echo "missing.txt not found"
Output
Y
Y
fallback
missing.txt not found

In 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.

Terminal
cat <<EOF
Sum is $((2+3))
EOF
echo ---
cat <<'EOF'
Literal: $HOME and $((2+3))
EOF
Output
Sum 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.

Terminal
if true; then
	cat <<-EOF
	indented with tab
	EOF
fi
echo ---
cat > config.ini <<EOF
[server]
port=8080
EOF
cat config.ini
Output
indented with tab
---
[server]
port=8080

The 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.

Terminal
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"
Output
1
y x
3
HELLO

grep -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.

Terminal
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-*
Output
hostname lines: 1
n=3
count: 3
backup-2026-10-03

Each $(...) 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.

Terminal
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`
Output
outer inner deepest
status after assignment: 1
[a]
backtick

The 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.

Terminal
diff <(printf 'a\nb\n') <(printf 'a\nc\n')
Output
2c2
< b
---
> c

diff 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.

Terminal
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]}"
Output
no pipefail: 0
pipefail: 1
---
0
status 2 0

Without 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.

Terminal
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: $?"
Output
rightmost failure: 4
single failure: 3
set -e status: 1
strict status: 1

The 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>&1 or |& to travel through it.
  • Redirections are processed from left to right. > file 2>&1 sends both streams to the file, while 2>&1 > file sends stderr to the terminal and stdout to the file.
  • sort file > file empties the file before sort reads it, because the shell opens the output first. Write to a temporary file and rename it, or use sort -o file file.
  • > replaces a file, >> appends, and set -o noclobber blocks 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. grep returns 1 for "no match".
  • $? reflects only the last command. Save it with rc=$? before running another command.
  • A && B || C is not an if-else. C also runs when B fails.
  • A pipeline returns the status of its last stage unless set -o pipefail is on. PIPESTATUS holds every stage's status in bash.
  • tee writes to both a file and stdout. sudo tee is 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>&1 points stderr at the current target of stdout, and the order of redirections changes the result.
  • /dev/null discards 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 3 end.
  • tee copies a stream to files and to stdout. -a appends.
  • 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.
  • <<WORD supplies 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 pipefail makes a pipeline return the rightmost nonzero status. set -euo pipefail is a strict-mode header.