Robust Bash: Strict Mode, Traps & Debugging
- Explain why the default shell hides failures, and turn that off with set -euo pipefail
- Guard a script's inputs and fail fast with clear stderr messages and distinct exit codes
- Clean up reliably with a trap on EXIT, no matter how the script ends
- Debug a misbehaving script with set -x, bash -x, and a useful PS4
- Recognise the pipefail gap and the unquoted-variable trap that survive even 'set -e'
The default shell is too forgiving
See the three silent failures strict mode turns into loud ones
By default, Bash keeps going after almost anything. A command fails? It runs the next one. You typo a variable name? It expands to the empty string. A command mid-pipeline dies? The pipeline still reports success. For an interactive shell that forgiveness is convenient. For a script that provisions a server or deletes files, it's how
rm -rf "$dir/"becomesrm -rf /when$dirwas a typo.Three short options flip those silent failures into immediate, visible stops. Together they're called strict mode, and they belong at the top of nearly every script you write.
Check yourself
With default settings, what does Bash do with a reference to a variable you never set?
Strict mode: set -euo pipefail
Turn on the three guards and know what each one does
One line, three guards:
- `-e` — exit the moment any command fails (non-zero) instead of continuing.
- `-u` — treat an unset variable as an error, not an empty string.
- `-o pipefail` — a pipeline fails if any stage fails, not just the last one.
Write it as
set -euo pipefailright after the shebang. Together they mean: the script stops at the first real problem, with the failing command's exit status.cat > strict.sh <<'EOF' #!/bin/bash set -euo pipefail dir=${1:?usage: strict.sh <dir>} count=$(ls "$dir" | wc -l) echo "$dir has $count entries" EOF cat strict.sh#!/bin/bash set -euo pipefail dir=${1:?usage: strict.sh <dir>} count=$(ls "$dir" | wc -l) echo "$dir has $count entries"- -e
- exit on the first command that returns non-zero
- -u
- error on use of an unset variable
- -o pipefail
- a pipeline's exit status is the first non-zero stage, not just the last
- ${1:?msg}
- require $1; abort with msg if it's unset/empty
The skeleton. set -euo pipefail is line 2. ${1:?msg} is strict mode's partner for arguments: if $1 is missing it prints 'usage: ...' and exits non-zero — you can't accidentally run with no directory. Because of pipefail, if 'ls' fails the whole 'ls | wc -l' fails and -e stops the script. Check yourself
Which part of 'set -euo pipefail' makes 'grep foo file | sort' fail when grep finds nothing?
Clean up with trap ... EXIT
Guarantee cleanup runs no matter how the script ends
Scripts create temporary things — a scratch file, a lock, a mounted directory. If the script exits early (an error under
set -e, aCtrl-C, akill), naive cleanup at the bottom never runs and the mess is left behind. `trap 'cleanup' EXIT` registers a handler that fires on every exit path — success, failure, or signal. Set it up before you create the thing, and cleanup becomes automatic.tmp=$(mktemp) trap 'rm -f "$tmp"' EXIT echo "working in $tmp" # ... even if the next line fails under set -e, $tmp is removed falseworking in /tmp/tmp.a1B2c3
- trap 'cmd' EXIT
- run cmd on every exit — normal, error, or signal
- mktemp
- create a uniquely-named temp file (no name collisions)
mktemp makes a unique scratch file; the trap on EXIT deletes it. Then 'false' fails — under set -e the script exits immediately — but the trap still fires and removes $tmp. Order matters: register the trap right after creating the resource, so any failure in between is still covered. Check yourself
Why register the EXIT trap immediately after creating the temp file, not at the end of the script?
Validate input, report to stderr, exit with meaning
Make the script's interface predictable for humans and callers
A robust script checks its inputs before acting, sends error and usage text to stderr (so it never pollutes captured output), and exits with a distinct non-zero code per failure mode so callers and CI can branch on why it failed. Guard clauses at the top keep the happy path clean.
cat > deploy.sh <<'EOF' #!/bin/bash set -euo pipefail name=${1:-} [ -n "$name" ] || { echo "usage: deploy.sh <name>" >&2; exit 2; } [ -f "/etc/app/$name.conf" ] || { echo "error: unknown config $name" >&2; exit 3; } echo "deploying $name" EOF cat deploy.sh#!/bin/bash set -euo pipefail name=${1:-} [ -n "$name" ] || { echo "usage: deploy.sh <name>" >&2; exit 2; } [ -f "/etc/app/$name.conf" ] || { echo "error: unknown config $name" >&2; exit 3; } echo "deploying $name"- >&2
- send this line to stderr — the channel for errors, not results
- exit 2 / exit 3
- distinct codes let callers branch on the failure reason
Two guard clauses, two exit codes. No argument → usage on stderr, exit 2. Unknown config → error on stderr, exit 3. The happy path ('deploying') is only reached when both guards pass. A caller can now tell a usage mistake (2) from a missing config (3) — and neither error lands in stdout that a pipeline might capture. Check yourself
Why give 'no argument' and 'unknown config' different exit codes instead of both exiting 1?
Debugging: watch the script run
Trace execution with set -x and a readable PS4
When a script does the wrong thing, stop guessing and watch it run. `set -x` (xtrace) prints each command after expansion, right before it executes — so you see the real values, not the source. Turn it on for a section (
set -x…set +x), or run the whole script withbash -x script.sh. A richer `PS4` prompt adds the line number so you know exactly where each traced line came from.PS4='+ ${BASH_SOURCE}:${LINENO}: ' set -x name=deploy dir=/srv/$name set +x+ script.sh:3: name=deploy + script.sh:4: dir=/srv/deploy + script.sh:5: set +x
- set -x / set +x
- turn execution tracing on / off
- PS4
- the trace prefix — add ${LINENO} to show line numbers
- bash -x script.sh
- trace an entire script without editing it
With xtrace on, each line is echoed AFTER expansion — you can see dir became /srv/deploy, the actual value. The custom PS4 prefixes the source file and line number, so in a long script you know precisely which line produced which trace. set +x turns it back off. Check yourself
You suspect a variable holds the wrong value halfway through a script. What's the fastest way to confirm without adding echo everywhere?
Now do it — harden a script in your terminal
Add strict mode, a cleanup trap, and a guard, then watch it run
Take a throwaway script from limp to robust in four edits: strict mode on top, a guard clause for input, a trap for cleanup, and a quick xtrace to confirm. This is exactly the shape the defensive-scripting and debugging labs will grade.
cat > h.sh <<'EOF' #!/bin/bash set -euo pipefail name=${1:?usage: h.sh <name>} tmp=$(mktemp) trap 'rm -f "$tmp"' EXIT echo "$name" > "$tmp" echo "wrote $(wc -l < "$tmp") line for $name" EOF bash h.sh alice- ${1:?usage...}
- require the argument; abort with the message if missing
A complete robust mini-script: strict mode, a required argument via ${1:?...}, a temp file with an EXIT trap that guarantees cleanup, and honest output. Run it with an argument (works) and then with NONE — watch ${1:?} stop it with the usage message and a non-zero exit. bash -x h.sh alice 2>&1 | head -6- bash -x
- run with xtrace on for the whole script
Trace the same script with bash -x — no edits needed — and read how each line expands. This is your first move whenever a script misbehaves: run it under -x and watch the real values flow through.
- set -euo pipefail is the robust-script floor: -e stops on the first failure, -u errors on unset variables, pipefail propagates a failing pipeline stage.
- Arm 'trap cleanup EXIT' right after creating a temp file/lock so cleanup runs on every exit path — success, error, or Ctrl-C.
- Validate inputs with guard clauses, send usage/errors to stderr (>&2), and exit with distinct non-zero codes so callers can branch on the reason.
- Debug by watching execution: set -x / set +x around a block, or bash -x whole-script; add ${LINENO} to PS4 for line numbers.
- Strict mode is a floor, not a ceiling — it ignores commands in if/&&, it can't fix quoting, and an unmatched glob still arrives literally (shopt -s nullglob fixes that), so keep every variable quoted and never leave xtrace on around secrets.
| Concept | Syntax |
|---|---|
| Strict mode | set -euo pipefail |
| Require an argument | name=${1:?usage: ...} |
| Guaranteed cleanup | trap 'rm -f "$tmp"' EXIT |
| Guard clause | [ -n "$x" ] || { echo msg >&2; exit 2; } |
| Error to stderr | echo "..." >&2 |
| Trace a block / a script | set -x … set +x · bash -x s.sh |
