Exit Codes and Error Handling
Every command that runs on Linux terminates with an integer exit status between 0 and 255, stored by the shell in the special variable $?. By convention, 0 means success and any non-zero value indicates some form of failure, though the specific non-zero meaning is defined by each program. This simple contract is the backbone of shell scripting: it's how if, &&, ||, and while decide what to do next, and it's why disciplined error handling — checking $?, using set -e, and choosing meaningful exit codes in your own scripts — separates fragile automation from production-grade tooling.
Cricket analogy: Just as the third umpire's decision comes back as a specific code — out for LBW, out for caught, or not-out — every Linux command reports an integer exit status where 0 is 'not out' and any nonzero value is a distinct kind of dismissal.
Reading and Using $?
Immediately after any command runs, $? contains its exit status. Because $? is overwritten by the very next command (even a simple assignment doesn't touch it, but running echo or ls would), you must capture it right away if you need it later: rc=$?. In conditionals, you rarely need $? explicitly — if command; then ... fi already tests the command's exit status directly, and command && next / command || fallback chain execution based on success or failure without ever naming $? at all.
Cricket analogy: A scorer must jot down the runs off a delivery the instant it's bowled, because the very next ball overwrites the previous tally on the board, so disciplined scorers save the figure to their own notebook before play continues.
#!/usr/bin/env bash
set -euo pipefail
curl -fsSL https://example.com/health -o /tmp/health.json
rc=$?
if [[ $rc -ne 0 ]]; then
echo "Health check failed with exit code $rc" >&2
exit 1
fi
# Idiomatic equivalent without capturing $? explicitly
if ! curl -fsSL https://example.com/health -o /tmp/health.json; then
echo "Health check failed" >&2
exit 1
fi
# Chaining based on success/failure
mkdir -p /var/backups/app && tar czf /var/backups/app/data.tar.gz /srv/app/data || {
echo "Backup failed" >&2
exit 2
}set -e, set -u, set -o pipefail
set -e (errexit) causes the script to exit immediately if any simple command returns non-zero, instead of silently continuing with a failed step. set -u (nounset) treats references to unset variables as errors, catching typos like $FILENAME when you meant $FILE_NAME. set -o pipefail changes the exit status of a pipeline to be the rightmost non-zero exit status among all its stages, instead of just the last command's — without it, grep pattern file | sort reports success even if grep found nothing, because sort's exit code (0) masks grep's. The idiom set -euo pipefail at the top of a script is considered a best practice for defensive Bash scripting, though it has known sharp edges (see the warning below).
Cricket analogy: Setting set -e is like a captain instructing that any dropped catch immediately ends the innings review rather than playing on as if nothing happened, while pipefail ensures a mis-fielded ball earlier in a relay throw isn't masked by a clean final throw to the keeper.
By POSIX convention, exit code 0 means success, 1 is a generic catch-all error, 2 often signals shell builtin misuse (e.g. bad syntax), 126 means the command was found but not executable, 127 means command not found, and 128+N means the process was terminated by signal N (e.g. 130 = 128+2 = terminated by SIGINT/Ctrl-C, 137 = 128+9 = killed by SIGKILL, often due to the OOM killer). Well-written scripts and tools document their specific non-zero codes so callers can branch on them (grep, for instance, returns 0 if a match was found, 1 if not, and 2 on an actual error).
set -e does NOT catch every failure. It's silently ignored for commands in conditional contexts (if, while, until, &&/||), for the last command in a pipeline when a preceding pipeline stage's real failure is masked, and for any command whose return value is being tested. A classic footgun: result=$(some_failing_command) does not abort under set -e if some_failing_command is inside a larger conditional context, or when its output is captured in certain function contexts — always test critical commands explicitly rather than relying purely on set -e for correctness-critical logic.
Trapping Errors and Choosing Exit Codes
The trap builtin lets a script run a handler when it receives a signal or exits, which is invaluable for cleanup (removing temp files, releasing locks) regardless of how the script terminates. trap 'cleanup' EXIT runs the cleanup function whenever the script exits, whether normally, via exit, or due to an uncaught error under set -e. When writing your own scripts, exit N at any point terminates immediately with status N; choosing distinct, documented codes (e.g. 1 = generic failure, 2 = bad usage, 3 = missing dependency) makes scripts composable in larger automation pipelines where callers branch on the specific code.
Cricket analogy: The trap builtin is like a groundskeeper who rolls out the covers whenever the match ends, whether by a result, a draw, or rain stopping play, ensuring the pitch is protected no matter how the day concluded.
#!/usr/bin/env bash
set -euo pipefail
TMPDIR=$(mktemp -d)
cleanup() {
rc=$?
rm -rf "$TMPDIR"
exit "$rc"
}
trap cleanup EXIT
trap 'echo "Interrupted!" >&2; exit 130' INT
echo "Working in $TMPDIR"
curl -fsSL https://example.com/data.csv -o "$TMPDIR/data.csv" || {
echo "Download failed" >&2
exit 3
}
echo "Done"- $? holds the exit status (0-255) of the most recently executed command; capture it immediately with rc=$? if needed later.
- 0 means success by convention; any non-zero value means failure, with the specific meaning defined by each program.
- set -e exits on any unhandled command failure, set -u errors on unset variables, and set -o pipefail propagates failures through pipelines.
- set -e has notable exceptions (conditionals, pipeline non-final commands, &&/||) — don't rely on it exclusively for critical error paths.
- trap 'handler' EXIT/INT/TERM runs cleanup code reliably regardless of how or why the script terminates.
- 128+N as an exit code indicates termination by signal N (e.g. 137 = SIGKILL, 130 = SIGINT).
Practice what you learned
1. What does the special shell variable $? contain immediately after a command runs?
2. Why is `set -o pipefail` important when using pipelines like `grep pattern file | sort`?
3. An exit code of 137 typically indicates what?
4. Which scenario is a known limitation of `set -e` (errexit)?
5. What is the primary purpose of `trap 'cleanup' EXIT` in a Bash script?
Was this page helpful?
You May Also Like
Writing Your First Bash Script
Get hands-on with the anatomy of a Bash script — shebang, permissions, execution, comments, and structuring commands into a reusable, repeatable tool.
Command-Line Arguments and getopts
Learn how Bash scripts receive positional parameters, how to shift and iterate over them, and how to build proper option parsing with getopts.
Conditionals in Bash (if, case)
Master Bash's conditional constructs — if/elif/else, test expressions, [[ ]] versus [ ], and case statements — to make scripts branch correctly on real-world conditions.
Killing and Signaling Processes
Understand Unix signals and the kill, killall, and pkill commands to gracefully terminate or forcibly stop misbehaving processes.
Related Reading
Related Study Notes in DevOps
Browse all study notesNginx Study Notes
DevOps · 30 topics
DevOpsAnsible Study Notes
DevOps · 30 topics
DevOpsAdvanced Kubernetes Study Notes
Kubernetes · 30 topics
DevOpsAdvanced Bash Scripting Study Notes
Bash · 30 topics
DevOpsApache Kafka Study Notes
Kafka · 30 topics
DevOpsDocker & Kubernetes Study Notes
YAML · 40 topics