Exit codes and error handling
$?, PIPESTATUS, set -e, and where each misses a failure
Every command sets an exit status when it finishes, and $? is where the shell keeps the most
recent one. Pipelines, set -e and trap all read that number, and each of them has a case
where it misses a failure.
Every command sets an exit status
When any command finishes, it sets an exit status: an integer from 0 to 255, stored until
the next command runs, readable via $?. Convention, not enforcement, says 0 means success and
anything else means some kind of failure:
true; echo $?
false; echo $?
0
1
$? only holds the most recent command's status. Check it immediately, or save it to a
variable, before running anything else, including an echo you might think is "free":
grep -q "error" app.log
status=$?
echo "grep exited $status"
What 126, 127 and 128+N mean
Beyond the generic 0/non-zero split, a handful of exit codes are conventional across most Unix tools:
nonexistent-command-xyz
echo "exit=$?"
bash: nonexistent-command-xyz: command not found
exit=127
chmod 644 noexec.sh
./noexec.sh
echo "exit=$?"
bash: ./noexec.sh: Permission denied
exit=126
127 means the shell couldn't find the command at all. 126 means it found it but couldn't
execute it, commonly a missing execute bit (see
File permissions explained). Signals add another
pattern: a process killed by signal N exits with 128 + N. kill -TERM on a running command
produces exit status 143 (128 + 15); Ctrl-C, which sends SIGINT (signal 2), produces 130.
Recognising these numbers tells you whether a script died from its own logic or was killed from
outside.
A pipeline's exit status is the last command's, by default
false | true
echo $?
0
Only true's exit status survives, and false's failure earlier in the pipe is invisible to
$?. Piping a command that might fail into grep, sort, or anything else silently discards
its exit code, which is a common way for a script's error handling to miss a real failure.
Two ways to see every stage's exit status instead of just the last one:
false | true
echo "${PIPESTATUS[@]}"
1 0
PIPESTATUS is a bash array holding the exit status of every command in the most recently run
pipeline, in order. Alternatively, set -o pipefail changes what $? itself reports for a
pipeline: the exit status of the last command that failed, or 0 if all of them succeeded.
bash -c 'set -o pipefail; false | true; echo $?'
1
Most scripts benefit from pipefail turned on near the top. Without it, a pipeline only reports
on its final stage.
set -e: stop on the first failure, with real exceptions
set -e (errexit) makes a script exit immediately when a command fails, instead of continuing
to the next line:
set -e
echo before
false
echo after
before
after never prints; the script stopped at false. But set -e has well-known blind spots:
a command's failure is not fatal when it's part of a condition (if, while, until), the
left side of &&/||, or negated with !:
set -e
if false; then echo yes; fi
echo "still running"
still running
Here false "fails" but the script keeps going, because false is being tested, not run for
its own sake. Without that exemption no script using set -e could check whether a command
succeeded without aborting, so it has to work this way. It does mean set -e alone is not a
substitute for checking exit codes yourself.
Reacting to a failure without aborting: traps
trap 'echo caught error, exit=$?' ERR
false
echo done
caught error, exit=1
done
trap ... ERR runs a command whenever any command in the script exits non-zero (subject to the
same exceptions set -e has), useful for logging, cleanup, or alerting without hand-checking
$? after every single line.
&& and ||: short-circuit on exit status
true && echo "ran because true succeeded"
false || echo "ran because false failed"
&& runs its right side only if the left side exited 0; || runs its right side only if the
left side exited non-zero. Chains of these are exit-status logic, not boolean logic on output;
this is the mechanism behind idioms like mkdir -p "$dir" && [cd](/commands/cd/) "$dir" and [command -v](/compare/which-vs-type-vs-command/) jq || echo "jq not installed".
Common misconceptions
- "A pipeline's exit status reflects whether the whole thing worked." Only with
pipefailset. Otherwise it's just the last command's status. - "
set -emakes a script bulletproof." It stops on unguarded failures, but conditions, negation, and the left side of&&/||are deliberately exempt, and a failing command inside a function called from a context that doesn't check its return can still slip through. - "Any non-zero exit means the same kind of failure."
1(generic failure),126(not executable),127(not found), and128+N(killed by signal N) mean different things. Treating them identically in a script's error handling throws away information the shell already computed for you. - "I need
$?right after the command, or I've lost it." True. Any command at all, including one you think of as a no-op likeechoor[[ ... ]], overwrites$?. Capture it into a variable the moment you need it for anything other than an immediate check.