Exit Codes and Strict Mode
Every command returns an exit code: 0 = success, non-zero = failure. Bash scripts that don’t check exit codes silently keep going past failures and produce confusing partial state. Strict mode (set -euo pipefail) makes failures actually stop the script.
Exit codes
ls /tmp; echo $? # 0
ls /no/such/dir; echo $? # 2
$? is the exit code of the last command. By convention:
| Code | Means |
|---|---|
| 0 | success |
| 1 | general error |
| 2 | misuse of shell builtins (often) |
| 126 | command found but not executable |
| 127 | command not found |
| 128 + N | killed by signal N (e.g. 130 = SIGINT/Ctrl-C, 137 = SIGKILL) |
| 1–125 | application-defined |
Set your own:
exit 0 # success
exit 1 # generic failure
exit 42 # arbitrary failure code
Conditional execution: && and ||
cmd1 && cmd2 # run cmd2 only if cmd1 succeeded
cmd1 || cmd2 # run cmd2 only if cmd1 failed
cmd1 && cmd2 || cmd3 # NOT if/then/else — see below
cmd1 && cmd2 || cmd3 looks like an if/then/else but isn’t:
- If
cmd1succeeds →cmd2runs. - If
cmd2then fails →cmd3runs anyway.
For real if/then/else, use if:
if cmd1; then
cmd2
else
cmd3
fi
Common “guard” pattern:
mkdir -p /tmp/foo || exit 1
cd /tmp/foo || exit 1
Strict mode
set -euo pipefail
| Flag | Effect |
|---|---|
-e |
exit immediately if any command exits non-zero |
-u |
error on use of undefined variable |
-o pipefail |
a pipeline fails if any of its commands failed (not just the last) |
Bonus often added:
IFS=$'\n\t' # safer field separator (no space)
This combination is sometimes called the “unofficial bash strict mode” (Aaron Maxwell’s term). Add it to every script.
-e gotchas
-e doesn’t trigger when:
- Command is in a condition:
if cmd; then ...— failure is part of the test. - Command is left of
&&or||. - Command is in a pipeline (without
pipefail). - Command exit code is captured with
$?immediately after. - Inside
((...))returning 0 (e.g.((counter++))when counter starts at 0 returns non-zero pre-increment).
The arithmetic gotcha bites a lot:
set -e
counter=0
((counter++)) # exits the script — pre-increment value is 0 = false
Workarounds:
((counter++)) || true
counter=$((counter + 1)) # safer
-u gotcha
-u errors on undefined vars. Combine with ${var:-default} to handle optional env vars cleanly:
set -u
echo "$DEBUG" # exits if DEBUG not set
echo "${DEBUG:-false}" # safe — defaults to "false"
For arrays, use ${arr[@]:-} to avoid unbound variable on empty arrays.
pipefail is critical
Without it:
set -e
false | true # exits 0 because last cmd succeeded
echo "got here" # prints — failure was masked
With set -o pipefail:
set -e -o pipefail
false | true # exits 1
echo "got here" # not printed
Always include pipefail. The number of bash scripts in production “succeeding” because of this is alarming.
trap — run code on exit / signals
cleanup() {
rm -rf /tmp/work_$$
echo "cleaned up"
}
trap cleanup EXIT
trap COMMAND EXIT runs the command when the script exits — for any reason (success, error, killed). Like a finally block.
Per-signal:
trap 'echo "interrupted"; exit 130' INT # Ctrl-C
trap 'echo "terminating"; cleanup; exit 143' TERM
trap 'echo "error at line $LINENO"; exit 1' ERR
| Signal | When |
|---|---|
EXIT |
script exits (normal or error) |
ERR |
any command fails (with -e, just before the script exits) |
INT |
SIGINT (Ctrl-C) |
TERM |
SIGTERM (kill) |
HUP |
SIGHUP (terminal closed) |
Combined with strict mode:
#!/usr/bin/env bash
set -euo pipefail
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
# ... do stuff in $work_dir ...
# Even if anything fails, $work_dir gets cleaned up
This pattern is the canonical “make a workspace, guarantee cleanup.”
Catching errors without dying
Sometimes you want to handle a failure, not exit:
set -e
if ! my_command; then
echo "command failed, but continuing"
fi
# or capture exit status:
my_command && rc=$? || rc=$?
echo "exit code was $rc"
# or wrap in a subshell:
(set +e; my_command); rc=$?
if cmd; then doesn’t trigger -e because the failure is part of the conditional test.
Error reporting with line numbers
trap 'echo "ERROR at $0:$LINENO: \"$BASH_COMMAND\" exited with status $?" >&2' ERR
set -eE # -E makes ERR trap inherit into functions
When something fails, you get the file, line, command, and exit code. Saves enormous amounts of debugging time.
shellcheck — the linter
shellcheck script.sh
Catches:
- Unquoted variables that will break on whitespace.
- Useless
cat file | grep ...(usegrep ... file). - Wrong quoting in
[[ ]]. - Misused
$?. - Many many more.
Install once, run on every script. Equivalent to mypy for Python — catches a huge class of bugs at parse time.
Defensive scripting checklist
Top of every script:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Helpful error trap
trap 'echo "ERROR at line $LINENO" >&2' ERR
# Required env vars
: "${API_KEY:?API_KEY is required}"
: "${DATABASE_URL:?DATABASE_URL is required}"
# Cleanup on exit
work_dir=$(mktemp -d)
trap 'rm -rf "$work_dir"' EXIT
That’s the “I’ve been bitten by every bash gotcha” preamble.
Common interview confusions
- “
set -eexits on every error.” — many exceptions: failedifconditions, commands in||/&&, pipelines (without pipefail), arithmetic returning 0. - “
$?reflects the previous command, even afterset -e.” — yes but you rarely need it;set -ealready exited if it failed. Usecmd && rc=$? || rc=$?to capture without dying. - “
trap EXITonly runs on success.” — runs on any exit (success, error, signal, normal). It’s the bashfinally. - “
set -eandpipefailare the same.” — different.-eexits on failure;pipefailmakes pipelines report failure of any stage. Both needed.
Interview angle
- “What’s bash strict mode?” —
set -euo pipefail(and oftenIFS=$'\n\t').-eexit on error,-uerror on unset var,pipefailso pipelines report stage failures. Standard hardening for any script. - “Why isn’t
set -eenough on its own?” — many cases bypass it: failed conditions inif, commands in||, pipelines (needpipefail), arithmetic returning 0. Plus undefined-variable bugs need-u. - “How do you guarantee cleanup runs even on error?” —
trap cleanup EXIT. Runs on any exit path. Combine withmktemp -dfor safe scratch directories. - “How do you handle a command that’s expected to sometimes fail under
set -e?” — wrap in anif(if ! cmd; then ...), or usecmd || true, or capture withcmd && rc=$? || rc=$?. - “What does
set -o pipefaildo thatset -edoesn’t?” —-eonly sees the last command’s exit in a pipeline.pipefailmakes the whole pipeline’s exit code reflect any stage’s failure. - “You see
exit 137from a process — what does it mean?” — killed by SIGKILL (signal 9, plus 128). Often OOM killer orkill -9.143means SIGTERM (15+128).