Exit codes and error handling
You've written command && echo ok || echo fail without thinking much about it. Here's what
$? actually is, why a pipeline can hide a failure from you, and what set -e does and doesn't
protect against.
Every command returns a number, not just output
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"
Specific numbers carry specific meanings
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; false's failure earlier in the pipe is invisible to $?.
This is the single most common way a script's error handling misses a real failure: piping a
command that might fail into grep, sort, or anything else silently discards its exit code.
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 is only as
reliable as its last 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. This is correct and necessary (otherwise no script using set -e could ever check
whether a command succeeds without aborting), but it means set -e alone is not a substitute for
actually checking exit codes where they matter.
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 "$dir" and command -v 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, as shown above. - "
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, and worth restating: 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.