Exit codes and error handling

$?, PIPESTATUS, set -e, and where each misses a failure

Updated 2026-08-22

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 pipefail set. Otherwise it's just the last command's status.
  • "set -e makes 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), and 128+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 like echo or [[ ... ]], overwrites $?. Capture it into a variable the moment you need it for anything other than an immediate check.