backend / linux bash / 06_exit_codes_strict_mode.md

Exit Codes and Strict Mode

6 interview angles 6 min read source

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 cmd1 succeeds → cmd2 runs.
  • If cmd2 then fails → cmd3 runs 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 ... (use grep ... 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 -e exits on every error.” — many exceptions: failed if conditions, commands in ||/&&, pipelines (without pipefail), arithmetic returning 0.
  • $? reflects the previous command, even after set -e.” — yes but you rarely need it; set -e already exited if it failed. Use cmd && rc=$? || rc=$? to capture without dying.
  • trap EXIT only runs on success.” — runs on any exit (success, error, signal, normal). It’s the bash finally.
  • set -e and pipefail are the same.” — different. -e exits on failure; pipefail makes pipelines report failure of any stage. Both needed.

Interview angle

  • “What’s bash strict mode?”set -euo pipefail (and often IFS=$'\n\t'). -e exit on error, -u error on unset var, pipefail so pipelines report stage failures. Standard hardening for any script.
  • “Why isn’t set -e enough on its own?” — many cases bypass it: failed conditions in if, commands in ||, pipelines (need pipefail), 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 with mktemp -d for safe scratch directories.
  • “How do you handle a command that’s expected to sometimes fail under set -e?” — wrap in an if (if ! cmd; then ...), or use cmd || true, or capture with cmd && rc=$? || rc=$?.
  • “What does set -o pipefail do that set -e doesn’t?”-e only sees the last command’s exit in a pipeline. pipefail makes the whole pipeline’s exit code reflect any stage’s failure.
  • “You see exit 137 from a process — what does it mean?” — killed by SIGKILL (signal 9, plus 128). Often OOM killer or kill -9. 143 means SIGTERM (15+128).