opscape

Bash scripting guide · part 18 of 32

Exit codes

What exit statuses mean, how $? works, the reserved values 126, 127 and 128+N, PIPESTATUS, and how to choose codes for your own scripts.

Every command finishes with an exit status, a number from 0 to 255. Zero means success; anything else means some kind of failure, and the command decides which number means what. The shell's whole control flow (if, while, &&, ||, set -e) is built on this one number, so knowing how it behaves is most of knowing how to write reliable scripts.

Reading $?

$? holds the status of the most recent command. "Most recent" is literal: an echo that prints $? is itself a command, so the next $? is the echo's status.

$? is replaced by every command

false
echo "false returned $?"
echo "and that echo returned $?"
output
false returned 1
and that echo returned 0

false exits 1. The first echo succeeds, so the second one sees 0. Save the value right away (rc=$?) if you need it later.

Statuses carry meaning

Many commands use more than two values. grep is the classic: 0 means a line matched, 1 means it ran fine but nothing matched, 2 means an error such as a missing file. Testing if grep ... treats "no match" and "file missing" the same way, which is not always what you want.

Three outcomes from grep

grep -q ERROR logs/app.log;   echo "match:    $?"
grep -q PANIC logs/app.log;   echo "no match: $?"
grep -q ERROR logs/missing.log; echo "error:    $?"
output
match:    0
no match: 1
grep: logs/missing.log: No such file or directory
error:    2

The log contains ERROR, so the first call returns 0. PANIC does not occur, so the second returns 1. The third file does not exist: grep prints a message and returns 2.

diff and cmp follow the same convention (0 same, 1 different, 2 trouble). test returns 0 for true and 1 for false. Check a command's man page before you assume "non-zero means broken".

Codes the shell reserves

Some values are produced by the shell itself rather than by the program:

Status Meaning
126 The file was found but could not be executed (no execute permission, or it is a directory)
127 Command not found
128+N The command was killed by signal N (130 = Ctrl-C/SIGINT, 137 = SIGKILL, 143 = SIGTERM)
2 Bash builtins use it for usage errors, and bash for syntax errors

127, 126 and 128+N in practice

no-such-command; echo "status $?"
printf 'echo hi\n' > notexec.sh
./notexec.sh; echo "status $?"
sleep 30 & kill $!; wait $!; echo "status $?"
output
/bin/bash: line 1: no-such-command: command not found
status 127
/bin/bash: line 3: ./notexec.sh: Permission denied
status 126
status 143

An unknown command gives 127. A file without the execute bit gives 126 with "Permission denied". The background sleep was sent SIGTERM (signal 15), and wait reports 128 + 15 = 143.

Setting your own status

A script's status is the status of the last command it ran, unless it calls exit N. A function's is its last command's, unless it calls return N. Only the low 8 bits are kept, so exit 256 is success and exit -1 is 255. Stay within 0 to 125 for your own codes so they never collide with the shell's.

exit and return without a number pass on the status of the last command that ran. That makes cd "$dir" || exit a complete error check: if cd fails with 1, the script exits with 1.

Exit statuses wrap at 256

bash -c 'exit 3';   echo "exit 3   -> $?"
bash -c 'exit 256'; echo "exit 256 -> $?"
bash -c 'exit -1';  echo "exit -1  -> $?"
output
exit 3   -> 3
exit 256 -> 0
exit -1  -> 255

3 comes through as is. 256 wraps to 0, so a script that counts errors and does exit $errors reports success after exactly 256 failures. -1 becomes 255. Cap such counts, or exit 1.

A script without exit returns its last command's status

check.sh
#!/usr/bin/env bash
echo "checking for the config file"
test -f data/missing.conf
bash check.sh; echo "script status: $?"
explain
output
checking for the config file
script status: 1

The script never calls exit, so its status is whatever test -f returned: 1, because the file does not exist. Adding a trailing echo done would silently turn it into 0.

Acting on a status

Most of the time you do not read $? at all. if cmd runs cmd and branches on its status; cmd1 && cmd2 runs cmd2 only on success; cmd1 || cmd2 only on failure; ! inverts a status. Testing $? explicitly is only worth it when you need to tell several non-zero values apart, and then a case is clearer than a chain of ifs.

Branch on a status directly or inspect the value

if grep -q 'TODO:' notes.txt; then echo "notes have a TODO"; fi
! grep -q PANIC logs/app.log && echo "no panics logged"
grep -q x nofile 2>/dev/null
case $? in
  0) echo found ;;
  1) echo "not found" ;;
  *) echo "grep failed" ;;
esac
output
notes have a TODO
no panics logged
grep failed

The if and the ! form never touch $?. The case captures the status of the third grep, which was 2 because nofile does not exist, so it reports a failure rather than "not found".

Pipelines

A pipeline's status is the status of its last command. Earlier failures are invisible unless you look at PIPESTATUS, an array with one status per stage, or turn on set -o pipefail (covered in Strict mode and its caveats). Like $?, PIPESTATUS is overwritten by the next command, so copy it first.

Find out which stage of a pipeline failed

cat no-such-file | sort | wc -l
echo "status=$? stages=${PIPESTATUS[*]}"
cat no-such-file | sort | wc -l
codes=("${PIPESTATUS[@]}")
echo "cat=${codes[0]} sort=${codes[1]} wc=${codes[2]}"
output
cat: no-such-file: No such file or directory
0
status=0 stages=1 0 0
cat: no-such-file: No such file or directory
0
cat=1 sort=0 wc=0

cat failed with 1, but wc happily counted zero lines and exited 0, so $? says the pipeline succeeded. Reading $? and PIPESTATUS in the same echo works because both are expanded before the echo runs; the second run copies the array into codes first, which is the safe habit when you need the values over several lines.

Statuses that get lost

Several constructs report their own status and hide the inner one. local x=$(cmd) and export x=$(cmd) return the status of local or export, which is 0. A plain assignment x=$(cmd) does keep cmd's status.

local hides a command substitution's failure

f() {
  local a=$(false); echo "local a=\$(false): $?"
  local b; b=$(false); echo "b=\$(false) on its own line: $?"
}
f
output
local a=$(false): 0
b=$(false) on its own line: 1

Declaring and assigning in one local statement reports local's own success, 0. Splitting the declaration from the assignment lets the failure through as 1.

Choosing codes for your scripts

For most scripts two values are enough: 0 and 1. Use 2 for "you called me wrong" to match builtins and many utilities. If callers really need to tell failures apart, document a few codes at the top of the script and keep them below 126. /usr/include/sysexits.h defines a set starting at 64 (EX_USAGE 64, EX_NOINPUT 66, EX_UNAVAILABLE 69, EX_CONFIG 78) that some programs follow, but nothing in the shell relies on them.

Related