Pipes, redirection and exit status

stdin, stdout, stderr, pipes and exit codes.

Beginner14 min · lesson 6 of 29

Every command reads input and writes output, and the shell decides where both go before the command starts. This lesson shows how to send a command's results into a file, keep its error messages apart from its results, connect commands with pipes, write to a file that only root may change, and let the exit status of one command decide whether the next one runs. Every later lesson, and every script you write, is built from these pieces, and most of their surprises come from the few rules below.

Standard output and standard error

A program starts with three files already open, each with a number called a file descriptor (the handle the kernel hands back for an open file, as the first lesson showed). Descriptor 0 is standard input, or stdin, where the program reads. Descriptor 1 is standard output, stdout, where it writes its results. Descriptor 2 is standard error, stderr, where it writes error messages and warnings. In a terminal all three are connected to the terminal, so results and errors appear mixed together. The examples write their files into a practice directory, so create it first; then give ls one file that exists and one that does not:

deploy@web01 · Ubuntu 26.04 LTS
$ mkdir -p ~/pipes
deploy@web01 · Ubuntu 26.04 LTS
$ ls -l /etc/hostname /etc/nope
ls: cannot access '/etc/nope': No such file or directory -rw-r--r-- 1 root root 6 Sep 26 20:01 /etc/hostname

The two lines look alike, but they travelled on different descriptors: the complaint about /etc/nope on stderr and the listing on stdout. ls reports problems as it checks the names and prints the listing afterwards, which is why the error comes first. It exits with status 2 because one of the names could not be accessed.

Redirection changes where a descriptor points. > FILE sends stdout into a file:

deploy@web01 · Ubuntu 26.04 LTS
$ ls -l /etc/hostname /etc/nope > ~/pipes/out.txt
ls: cannot access '/etc/nope': No such file or directory
$ cat ~/pipes/out.txt
-rw-r--r-- 1 root root 6 Sep 26 20:01 /etc/hostname

Only the error reached the screen; the listing is in the file. > is short for 1>, and 2> does the same for stderr:

deploy@web01 · Ubuntu 26.04 LTS
$ ls -l /etc/hostname /etc/nope 2> ~/pipes/errors.txt
-rw-r--r-- 1 root root 6 Sep 26 20:01 /etc/hostname
$ cat ~/pipes/errors.txt
ls: cannot access '/etc/nope': No such file or directory

/dev/null is a device file that discards everything written to it. Sending stderr there hides messages you expect and do not care about, such as the "Permission denied" lines find prints for directories you may not read (the next lesson uses it for exactly that).

deploy@web01 · Ubuntu 26.04 LTS
$ ls -l /etc/hostname /etc/nope 2>/dev/null
-rw-r--r-- 1 root root 6 Sep 26 20:01 /etc/hostname

The exit status is still 2: throwing the message away does not change the result. Hide errors only after you have read them once and know they are noise.

Writing to files: >, >> and <

> creates the file if it does not exist and empties it if it does. >> adds to the end instead:

deploy@web01 · Ubuntu 26.04 LTS
$ echo "first" > ~/pipes/notes.txt echo "second" > ~/pipes/notes.txt cat ~/pipes/notes.txt
second
$ echo "third" >> ~/pipes/notes.txt cat ~/pipes/notes.txt
second third

The second > wiped out "first". That is what you want for a report you regenerate, and a disaster for a log you meant to keep. The shell sets up every redirection before it starts the command, so > empties the file even before the command has read a byte. Sorting a file into itself shows the consequence:

deploy@web01 · Ubuntu 26.04 LTS
$ printf "web02\nweb01\ndb01\n" > ~/pipes/hosts.txt sort ~/pipes/hosts.txt > ~/pipes/hosts.txt wc -c ~/pipes/hosts.txt
0 /home/deploy/pipes/hosts.txt

The shell opened hosts.txt for writing and cut it to zero bytes, then sort read the now empty file and wrote nothing. The three names are gone, and grep ... file > file or sed ... file > file lose data the same way. Write to a new file and rename it afterwards, or use an option that does this for you, such as sort -o:

deploy@web01 · Ubuntu 26.04 LTS
$ printf "web02\nweb01\ndb01\n" > ~/pipes/hosts.txt sort -o ~/pipes/hosts.txt ~/pipes/hosts.txt cat ~/pipes/hosts.txt
db01 web01 web02

If you want the shell to protect existing files, set -o noclobber makes > refuse to overwrite them (>| overrides it for one command). The option lasts until the shell exits or you switch it off with set +o noclobber, as the last line here does so that the rest of the lesson works as shown; put it in ~/.bashrc, covered in the lesson on quoting and aliases, to keep it.

deploy@web01 · Ubuntu 26.04 LTS
$ set -o noclobber echo "oops" > ~/pipes/notes.txt set +o noclobber
-bash: line 2: /home/deploy/pipes/notes.txt: cannot overwrite existing file

The -bash: line 2: prefix appears because the terminals in this course were recorded by running each command from a script. At an interactive prompt the same error starts with -bash:.

Redirection works in the other direction too. < FILE connects a file to a command's stdin. Many commands accept a file name or stdin, and the difference shows in their output:

deploy@web01 · Ubuntu 26.04 LTS
$ wc -l /etc/passwd wc -l < /etc/passwd
35 /etc/passwd 35

Given a name, wc -l opens the file itself and prints the name next to the count. Reading stdin, it never learns the name, so it prints the bare number, which is handier in a script.

Both streams in one file: the order matters

To capture everything a command prints, say for a ticket, send stdout to the file and then point stderr at the same place. 2>&1 means "make descriptor 2 point wherever descriptor 1 points right now", and the shell applies redirections from left to right.

deploy@web01 · Ubuntu 26.04 LTS
$ ls -l /etc/hostname /etc/nope > ~/pipes/all.txt 2>&1
$ cat ~/pipes/all.txt
ls: cannot access '/etc/nope': No such file or directory -rw-r--r-- 1 root root 6 Sep 26 20:01 /etc/hostname

Nothing reached the screen and the file holds both lines. Swap the two redirections and the result changes:

deploy@web01 · Ubuntu 26.04 LTS
$ ls -l /etc/hostname /etc/nope 2>&1 > ~/pipes/all.txt
ls: cannot access '/etc/nope': No such file or directory
$ cat ~/pipes/all.txt
-rw-r--r-- 1 root root 6 Sep 26 20:01 /etc/hostname

Read it left to right. 2>&1 came first, when descriptor 1 still pointed at the terminal, so stderr was pointed at the terminal. Only then did > move stdout to the file. Bash also accepts &> FILE as a shorter spelling of > FILE 2>&1, and >> FILE 2>&1 appends both.

Where the two streams go
ls -l /etc/hostname /etc/nope
listing on stdout (1), error on stderr (2)
no redirection
Both on the screen
the terminal shows 1 and 2 together
> out.txt
Listing in the file, error on screen
> moves descriptor 1 only
2> err.txt
Error in the file, listing on screen
2> moves descriptor 2 only
> all.txt 2>&1
Both in the file
2 is pointed where 1 already points
2>&1 > all.txt
Error on screen, listing in the file
2 copied the terminal before 1 moved
| wc -l
Only the listing enters the pipe
a pipe carries descriptor 1

Pipes, and tee

A pipe, written |, connects the stdout of the command on its left to the stdin of the command on its right. Both commands run at the same time and no temporary file is involved. How many programs are there in /usr/bin?

deploy@web01 · Ubuntu 26.04 LTS
$ ls /usr/bin | wc -l
1238

ls prints one name per line when its output is not a terminal, so wc -l counts programs. A pipe carries stdout only, and errors still go to the screen:

deploy@web01 · Ubuntu 26.04 LTS
$ ls -l /etc/hostname /etc/nope | wc -l
ls: cannot access '/etc/nope': No such file or directory 1
$ ls -l /etc/hostname /etc/nope 2>&1 | wc -l
2

In the first command the error bypassed the pipe and wc counted one line. Redirecting stderr into stdout before the pipe sends both into it, and bash accepts |& as a short form of 2>&1 |. The next lesson combines this with find, and the text-processing lesson builds longer pipelines.

tee is a T-junction for a pipeline: it copies its stdin into a file and passes it on to its stdout. Use it when you want to keep what went through a pipe and still see or process it.

deploy@web01 · Ubuntu 26.04 LTS
$ ls /usr/bin | tee ~/pipes/programs.txt | wc -l
1238
$ wc -l ~/pipes/programs.txt
1238 /home/deploy/pipes/programs.txt

sudo and redirection

Now save the SSH server's effective configuration into root's home directory, /root, which only root may enter. Putting sudo in front looks like it should work:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo sshd -T > /root/sshd-effective.txt
-bash: line 1: /root/sshd-effective.txt: Permission denied

The message comes from bash, not from sshd or sudo. Your shell runs as deploy, and it opens the file for the redirection before it starts sudo; deploy may not create files in /root, so the command never runs. sudo raises the privileges of the command it starts, never of your shell's redirections. sudo echo "192.0.2.10 db01" >> /etc/hosts fails for the same reason. The fix is to let a program that runs as root open the file, and tee is the usual choice:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo sshd -T | sudo tee /root/sshd-effective.txt > /dev/null
$ sudo ls -l /root/sshd-effective.txt
-rw-r--r-- 1 root root 4688 Sep 27 08:07 /root/sshd-effective.txt

sudo tee writes the file as root, and > /dev/null throws away the copy tee would print on your screen. sudo tee -a appends, like >>. Another way is to give the whole command line to a shell that runs as root: sudo sh -c 'sshd -T > /root/sshd-effective.txt'.

Exit status and chaining commands

The first lesson introduced the exit status: 0 means success, anything else failure, and $? holds the status of the last command. Some commands answer a question through it. grep -q WORD FILE looks for lines containing WORD (the next lesson covers grep), prints nothing, and exits 0 when it found a match, 1 when it found none and 2 when it could not read the file:

deploy@web01 · Ubuntu 26.04 LTS
$ grep -q "^deploy:" /etc/passwd echo $? grep -q "^nosuchuser:" /etc/passwd echo $?
0 1

Three operators join commands on one line and decide from that status what runs next. ; runs the next command whatever happened. && runs it only if the previous command succeeded, and || only if it failed:

deploy@web01 · Ubuntu 26.04 LTS
$ mkdir -p ~/pipes/backup && cp /etc/hostname ~/pipes/backup/ && echo "backup done"
backup done
$ grep -q "^nosuchuser:" /etc/passwd || echo "no such user"
no such user

The difference matters most after cd. When cd fails, a ; lets the next command run in whatever directory you were already in:

deploy@web01 · Ubuntu 26.04 LTS
$ cd /var/nope ; pwd
-bash: line 1: cd: /var/nope: No such file or directory /home/deploy
$ cd /var/nope && pwd
-bash: line 1: cd: /var/nope: No such file or directory

pwd is harmless, but put a delete in its place and it runs in your home directory. Chain steps that depend on each other with &&. In a script, write cd DIR || exit 1, or start the script with set -e, which stops it at the first command that fails (bash(1) lists the exceptions, such as commands tested with && or ||). One more trap: a && b || c is not "if a then b else c", because c also runs when b fails.

A pipeline has one exit status, and it is the status of its last command:

deploy@web01 · Ubuntu 26.04 LTS
$ cat /etc/nope | wc -l echo "exit $?, PIPESTATUS ${PIPESTATUS[@]}"
cat: /etc/nope: No such file or directory 0 exit 0, PIPESTATUS 1 0

cat failed with 1, but wc counted zero lines successfully, so $? is 0 and a check on it would report success. Bash keeps each command's status in PIPESTATUS, an array (a variable that holds a list; ${PIPESTATUS[@]} prints all of it), which is overwritten by the next command, so read it immediately. set -o pipefail makes a pipeline fail when any command in it fails, and scripts usually set it together with set -e:

deploy@web01 · Ubuntu 26.04 LTS
$ set -o pipefail cat /etc/nope | wc -l echo $?
cat: /etc/nope: No such file or directory 0 1
Exit status is the real result
A message on the screen can be hidden with 2>/dev/null, and output can look complete when a command in a pipeline failed. When a script or a check depends on a command, test its status (&&, ||, PIPESTATUS, pipefail) rather than whether some output appeared.

Try this

Run cat /etc/hostname /etc/shadow > ~/pipes/report.txt 2>&1 (in the ~/pipes directory from the start of the lesson). Before you open the file, predict how many lines it holds and which one is the error, then check with cat: the host name, then cat: /etc/shadow: Permission denied. Next run cat /etc/hostname /etc/shadow 2>/dev/null | wc -l, then echo ${PIPESTATUS[@]}, and explain both numbers (1, then 1 0). Finally append a line to a file only root can write with echo "checked" | sudo tee -a /root/pipes-check.txt, confirm it with sudo cat /root/pipes-check.txt, and remove it with sudo rm.

Takeaway

Your shell sets up redirections as you, before the command starts: use > file 2>&1 in that order to capture everything, | sudo tee to write where only root may, and && between steps that depend on each other. Judge a command by its exit status, including every command of a pipeline, not by what reached the screen.

Quick check
01A script runs ./backup.sh 2>&1 > backup.log, yet backup.sh's error messages still appear on the screen and are missing from backup.log. Why?
Correct — Redirections apply left to right, and 2>&1 copies wherever descriptor 1 points at that moment, which was still the terminal.
Incorrect — Programs write errors to descriptor 2, which is exactly what 2>&1 redirects. The problem is when the redirection happened.
Incorrect — The shell creates the file for > whether it exists or not. Existence has nothing to do with where stderr goes.
Incorrect — 2> and 2>&1 redirect errors routinely. Written as > backup.log 2>&1, both streams land in the file.
02As deploy you run sudo echo "192.0.2.10 db01" >> /etc/hosts and get "-bash: /etc/hosts: Permission denied". What happened?
Incorrect — sudo runs /usr/bin/echo happily. It never got that far: the error came from bash before sudo started.
Incorrect — root can edit /etc/hosts. The write was attempted by your own shell, not by root.
Incorrect — -E only concerns environment variables. Redirections are never performed by sudo, with or without options.
Correct — The shell sets up >> before it runs sudo, and deploy may not write /etc/hosts. tee started by sudo opens the file as root.
03A check runs cat /var/log/app.log | wc -l and then echo $? to detect a missing log. After someone deletes the log, it still prints 0. Why?
Incorrect — cat never creates files; it failed with "No such file or directory" and exit status 1.
Correct — wc counted zero lines without error. PIPESTATUS shows 1 0, and set -o pipefail would make the pipeline fail.
Incorrect — $? follows the last command of the pipeline. Had wc failed, $? would not be 0.
Incorrect — wc exits 0 for zero lines too. A count of 0 is a successful answer, not proof that a file exists.

Related