Module 14 — Error Handling and Defensive Scripting
Updated 3 September 2026
🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
Part A — Strict mode: three switches that change the default
A1. set -e, set -u, set -o pipefail
Bash's factory defaults were tuned for a human at a prompt: keep going after failures, treat unset variables as empty, let pipelines report only their last stage. Twelve modules have shown you what those defaults cost in scripts. Three switches, canonically written as one line at the top of the script, flip them:
set -euo pipefail-e (errexit): any command failing (non-zero, unguarded) ends the script immediately with that verdict — the ;-glue parade (Module 3 B1) stops at the first fire. -u (nounset): expanding an unset variable becomes a fatal error instead of silent emptiness — Module 2's oldest hazard, finally loud. -o pipefail: a pipeline's verdict is its first failure, not its last stage's — Module 4's lying pipeline, cured. Together they invert the philosophy: instead of asking "did I remember to check this?", failures interrupt you unless you explicitly said they're fine.
Where the analogy stops working. Dead-man switches are dumb and total. set -e is neither — it deliberately ignores failures in guard positions (an if's condition, a &&/|| chain) because those failures are questions being answered, not accidents. Part B maps those exceptions precisely, because half of all strict-mode confusion is expecting a total switch and getting a discerning one.
🧪 Exercise 14.1 — the three switches, thrown one at a time
All three scripts fail on purpose — that is now the point.
cd ~/bash-course
printf '#!/bin/bash\nset -e\necho "step 1"\nfalse\necho "step 2 (never)"\n' > se.sh
bash se.sh ; echo "verdict: $?"
bash -c 'set -u; echo "before"; echo "$TYPO_VAR"; echo "after"' ; echo "verdict: $?"
bash -c 'set -o pipefail; false | true; echo "pipefail verdict: $?"'✅ Expected result — click to reveal (contains deliberate failures)
step 1
verdict: 1
before
bash: line 1: TYPO_VAR: unbound variable
verdict: 127
pipefail verdict: 1What to read out of it: -e stopped the parade at false — step 2 never printed, and the script's verdict is the failure's (Module 3's truthful-verdict goal, enforced for free). -u turned the silent empty expansion into a named error — unbound variable, with the variable's name — killing Module 2's whole bug family (and Module 9's ${var:-} defaults remain the sanctioned way to say "empty is fine here"). pipefail made false | true finally confess (compare Module 4's Exercise 4.8: same pipeline, verdict 0). Three demos, three defaults inverted.
Interview questions — Part A
🎯 "What are set -u and set -o pipefail in Bash?" — asked verbatim at Zero To Mastery; LinuxTeck's FAQ asks "What is the difference between set -e and set -o pipefail?"
The direct answer: set -u makes expansion of unset variables a fatal error (typos and missing config die loudly instead of expanding to ""); set -o pipefail makes a pipeline return the first non-zero stage's status instead of only the last stage's. They address different failure channels — -e alone sees only each command's verdict, and a pipeline "command" lies without pipefail; that difference is LinuxTeck's question.
Going deeper: -u coexists with intentional defaults via Module 9's operators ("${TIMEOUT:-30}" is not an unset expansion — the default satisfies it), which is exactly how scripts stay strict and configurable. pipefail changes the verdict, while PIPESTATUS still holds the per-stage detail when you need to know which stage.
The details that separate candidates: the grep-under-pipefail consequence — a no-match grep (exit 1, Module 3) now fails the pipeline, so "no matches is fine" must be said explicitly: grep pattern file || true; and knowing -u has a special-parameter exception ("$@" is safe when there are zero arguments) — the two corners where strictness meets legitimate emptiness.
🎯 "How do you handle errors in Bash scripts?" — asked verbatim at Zero To Mastery; Hirist's variant appeared in Module 13
The direct answer, as layers: strict mode at the top (set -euo pipefail); Module 9's ${var:?} guards for required inputs; explicit command || handle where a failure has a local answer (retry, fallback, skip); an ERR trap for located last words (trap '…$LINENO…' ERR, Part C); an EXIT trap for cleanup (Module 13); and meaningful exit codes out the bottom (Module 3).
Going deeper: the philosophy that organizes the layers — strict mode is the safety net, not the error handling: it guarantees unhandled failures stop the script; the || branches are the actual handling, written only where you have something real to do about the failure. Scripts that "handle" everything with || true have removed the net and installed nothing.
The details that separate candidates: the retry-with-backoff loop for transient failures (for i in 1 2 3; do cmd && break; sleep $((i*2)); done — Modules 6+3 composed) presented as selective handling; and naming the observability half — errors logged with timestamps and line numbers to stderr/file, because at 3 a.m. "the script stopped" is a fact but "line 141, exit 7, at 02:13:22" is a diagnosis.
Part B — The sharp edges of -e (know them or be surprised)
B1. Where errexit deliberately looks away
set -e does not fire for failures in guard positions — places where a non-zero status is information, not accident: the condition of if/while/until; any command on the left of && or ||; any pipeline stage except the last (unless pipefail); and a command whose whole line is negated with !. This is by design — without it, if grep -q… could never say "no" — and the bash manual enumerates the list precisely. Two consequences are famous enough to test:
The && surprise: set -e; false && true does not stop the script — false is on the left of &&, a guard position — and the compound's failure is then also forgiven (it "was tested"). Scripts die less often than their authors expect around && chains.
The command-substitution surprise: n=$(false) on its own line does trip -e (the assignment's verdict is the substitution's — Module 3 D1's subtlety) — but local n=$(false) does not (local's own success wins — Module 8's SC2155, now with teeth: under strict mode the split-declaration rule is not style, it is whether failures are caught at all).
🧪 Exercise 14.2 — the edges, observed
Nothing here stops the script — that is the lesson.
set -e
if false; then echo yes; else echo "false inside if: allowed"; fi
false && true
echo "survived the && chain"
n=$(false) || echo "substitution failure, handled locally"
echo "still alive at the end"
set +e # switch strictness back off for your interactive shell✅ Expected result — click to reveal
false inside if: allowed
survived the && chain
substitution failure, handled locally
still alive at the endWhat to read out of it: four failures, zero deaths — every one sat in a guard position. Line 2's survival is the one to memorize (false && true under -e: left-of-&& is examined, and the chain's overall failure is forgiven with it). The || echo on the substitution shows the sanctioned pattern: touch the verdict and you own it — which is what "handled" means. The closing set +e matters when experimenting interactively: strict mode in your login shell turns every typo into a closed terminal.
Interview questions — Part B
🎯 "Why does set -e not work inside an if statement?" — asked verbatim in LinuxTeck's FAQ
The direct answer: because it's not supposed to — -e exempts commands in tested positions (if/while conditions, &&/|| operands, negated commands), since a non-zero status there is the question's answer, not an error. if grep -q x file must be allowed to "fail" or conditionals could not exist under strict mode.
Going deeper: recite the exemption list from the manual (conditions, chain operands except the last, non-final pipeline stages sans pipefail, !) and the corollary that bites: everything inside the body of a function called from a condition is also exempt — if my_complex_check; then silences -e for the whole call tree of my_complex_check, which is why deep checks are better run as plain guarded commands than as if-conditions.
The details that separate candidates: the whole-call-tree fact (few candidates know -e's exemption is dynamic, not lexical); the local n=$(cmd) masking as -e's most common real-world bypass (with the split-declaration fix); and the mature framing — these edges are why some teams reject set -e in favor of explicit || die everywhere, and being able to argue both policies calmly is worth more than either policy.
Part C — The rest of the armor: ERR traps, mktemp, locks
C1. ERR trap: a located last word
Strict mode stops the script; the ERR trap makes the stop articulate. trap 'echo "error at line $LINENO: $BASH_COMMAND (exit $?)" >&2' ERR runs your handler at the moment an -e-triggering failure occurs, with three forensic variables live: the line number, the exact command text, the verdict. Route it to a file (>> "$LOGFILE" — LinuxTeck's FAQ question about logging errors, answered) and unattended failures leave coordinates instead of mysteries. ERR obeys the same guard-position exemptions as -e, and pairs with it: -e decides that you stop, ERR decides what the world learns.
🧪 Exercise 14.3 — a strict script that names its own failure
The script fails on purpose — articulately.
cd ~/bash-course
nano strict.sh # six lines:#!/bin/bash
set -euo pipefail
trap 'echo "error at line $LINENO: $BASH_COMMAND (exit $?)" >&2' ERR
echo "step 1"
grep -q missing /etc/hosts
echo "never reached"chmod +x strict.sh
./strict.sh ; echo "verdict: $?"✅ Expected result — click to reveal (contains a deliberate failure)
step 1
error at line 5: grep -q missing /etc/hosts (exit 1)
verdict: 1What to read out of it: the failing line's number, its text, and its verdict, on stderr, unprompted — this is Module 8's die() upgraded from "you must call me" to "I catch what you forgot." The 3 a.m. difference is total: without the trap, the log ends at "step 1" and the investigation starts from nothing; with it, the investigation starts finished. Add $(date) and the script's name ($0) to taste — the handler is just a bash string, and everything from twelve modules is available inside it.
C2. mktemp and flock: safe scratch, single instance
Two remaining habits complete the defensive kit. Safe temp files: hardcoded scratch paths (/tmp/work.txt) collide between simultaneous runs and invite symlink mischief in shared /tmp; mktemp (file) and mktemp -d (directory) create uniquely-named, correctly-permissioned scratch atomically and print the path — always paired with Module 13's EXIT trap, acquire-trap-work. Single instance: cron jobs overlap when a run outlasts its interval (the 2 a.m. backup still running at 3 a.m. meets its successor); flock takes an exclusive lock on a file, and -n makes the second taker fail instantly instead of queueing:
exec 9>/var/lock/myjob.lock # open fd 9 on the lock file (Module 4's exec rewiring)
flock -n 9 || { echo "another run active; exiting" >&2; exit 0; }
# ... sole-survivor work ...The kernel releases the lock when the process exits — any exit, SIGKILL included, which is precisely what makes flock sturdier than hand-rolled "create a lock file, delete it at the end" schemes (Module 13's E2 stale-lock row: that whole failure class belongs to hand-rolled locks; flock's evaporate).
🧪 Exercise 14.4 — one job at a time
The second acquisition is refused on purpose.
lock=/tmp/demo.lock
exec 9>"$lock"
flock -n 9 && echo "lock acquired; working"
( exec 9>"$lock"; flock -n 9 && echo "second acquire ok" || echo "second run refused: already locked" )✅ Expected result — click to reveal (contains a deliberate refusal)
lock acquired; working
second run refused: already lockedWhat to read out of it: the subshell (Module 12 — a stand-in for a second cron invocation) opened the same lock file and was bounced immediately — -n's fail-fast, which for cron overlap is exactly right (exit 0, quietly: "my twin is on duty" is not an error). Release the lock by closing your shell (or exec 9>&-) and the second taker would succeed. One exec + one flock line at the top of any cron script retires the entire overlapping-runs incident class.
Interview questions — Part C
🎯 "How do I log errors to a file in a bash script?" — asked verbatim in LinuxTeck's FAQ
The direct answer: append stderr at the invocation (./job.sh 2>> /var/log/job.err — Module 4), or self-log inside: exec 2>> "$LOGFILE" rewires the script's own stderr permanently (Module 12's exec-without-command), and an ERR trap adds structured lines — timestamp, $0, $LINENO, $BASH_COMMAND, $? — at each failure.
Going deeper: keep the two channels honest — results still to stdout, diagnostics to the (now file-backed) stderr — so capturing output remains clean; timestamp every line (a log() helper, Module 8) because "when" is half of every incident; and append (>>) always, with rotation left to logrotate rather than the script.
The details that separate candidates: exec 2> >(tee -a "$LOG" >&2) for log-and-still-see (process substitution, Module 12) — plus knowing when not to bother: cron already mails/captures output, and systemd journals it; integrating with the platform's logging beats reinventing it, and saying so is the senior answer.
🎯 "How do you prevent two instances of a cron script from running at once?" — a fixture of ops screens; published lists rarely word it (flock's own man page carries the canonical patterns), so treat the topic as the question
The direct answer: flock — exec 9>/var/lock/job.lock; flock -n 9 || exit 0 at the top; the kernel holds the lock for the process's lifetime and releases on any exit, crash and SIGKILL included.
Going deeper: why hand-rolled lock files fail — delete-at-end skips on kill (stale lock, Module 13's E2), and check-then-create is a race (two runs both see "no lock" and both proceed); flock's acquisition is atomic and its release is kernel-guaranteed, closing both holes. The -n vs waiting choice: cron overlap wants fail-fast-exit-0; a queue-worker wrapper might prefer waiting with a timeout (-w 60).
The details that separate candidates: the one-liner self-locking wrapper from flock's man page (flock -n /var/lock/job.lock ./job.sh in the crontab itself — no script changes at all); putting locks on a local filesystem (flock over NFS is a different, murkier story); and the observability touch — logging "skipped: previous run active" so silent skips are countable, because a job that skips every night is an incident wearing camouflage.
Part D — The layered defense, drawn
D1. Where each failure gets caught
Diagram source
flowchart TD
A["a command fails<br>somewhere in the script"] --> B{"is its verdict<br>examined?<br>(if, &&, ||, !)"}
B -->|"yes"| C["your explicit handling runs:<br>retry / fallback / skip / die"]
B -->|"no"| D{"strict mode<br>set -euo pipefail?"}
D -->|"yes"| E["ERR trap speaks:<br>line, command, code"]
E --> F["EXIT trap cleans:<br>temp, locks, state"]
F --> G["script exits with<br>the failure's verdict"]
D -->|"no"| H["carry-on default:<br>failure compounds silently"]
H --> I["Module 3 Ticket 1:<br>green and empty backups"]
C --> J{"handled fully?"}
J -->|"yes"| K["script continues,<br>deliberately"]
J -->|"no — re-raise"| GRead any incident against this map: the question is never "why did it fail?" first — it is "which layer should have caught it, and why didn't it?" Missing strict mode, a guard position nobody meant, a local masking a substitution: the map names the usual holes.
Interview questions — Part D
🎯 "Design the error-handling for an unattended nightly script — what goes at the top and why?" — the whiteboard form of this module; every fragment has appeared verbatim across ZTM's and LinuxTeck's questions, and interviewers assemble them exactly like this
The direct answer, as the six-line preamble: #!/bin/bash; set -euo pipefail; ERR trap logging timestamp + $0 + $LINENO + $BASH_COMMAND + $? to the job's log; : "${REQUIRED_VAR:?...}" per input; tmpdir=$(mktemp -d) immediately followed by the EXIT-trap cleanup; exec 9>lock; flock -n 9 || exit 0 if cron-driven. Then the work, with explicit || handle only where a failure has a local answer.
Going deeper: justify each line by the failure it intercepts (typo'd variable → -u; lying pipeline → pipefail; overlap → flock; mid-run kill → EXIT trap; mystery stop → ERR trap) — the reasoning is the answer; the lines are just its residue.
The details that separate candidates: naming strict mode's exemptions unprompted (and the local masking) as the holes that remain; ShellCheck in CI as the enforcement that keeps the preamble present next quarter; and the closing judgment that this is a template, stamped not retyped — teams that template the preamble have it everywhere, teams that don't have it somewhere.
Part E — Production
E1. 🏭 Production practices
The six-line preamble is a stamped template, not a memory test. Shebang, strict mode, ERR trap, required-var guards, mktemp+EXIT trap, flock where cron-driven — new scripts start from the template (Module 8's, final form).
ShellCheck gates CI. Every .sh in the repo passes, warnings are fixed or explicitly waived with a justifying comment (# shellcheck disable=SCXXXX # reason) — silent waivers fail review.
|| true requires a comment. Each one asserts "this failure is genuinely fine"; unexplained ones are treated as deleted error handling.
Failures are articulate: ERR-trap lines carry timestamp, script, line, command, and code; skipped-due-to-lock runs are logged and counted (a job skipping nightly is an incident in camouflage).
Strictness is tested, not assumed: the template's edges (local split-declarations, grep || true, guard-position awareness) are review-checklist items, because -e's exemptions are where strict-mode scripts quietly stop being strict.
Interactive shells stay non-strict. Strict mode is for scripts; a set -e in ~/.bashrc closes your terminal on the first typo — the one place this module's advice inverts.
E2. Production-practice table
| Symptom | What is really happening | What to run | The fix |
|---|---|---|---|
| Strict-mode script sailed past a failure anyway | The failure sat in a guard position (if/while condition, left of && or ||) or behind a local/export masking | Re-read the line against the exemption list; test with the failure isolated on its own line | Split declarations (local n; n=$(cmd)); keep deep checks out of if-conditions; know the map |
| Script dies instantly under -u at a variable that is supposed to be optional | -u treats every unset expansion as fatal — including your intentionally-optional ones | Find the bare $OPTIONAL expansions | Module 9 defaults: "${OPTIONAL:-}" — explicit emptiness is -u-proof |
| Pipeline with grep started "failing" after pipefail was added | grep's no-match exit 1 now fails the pipeline — a meaning change, not a bug | grep pattern file; echo $? — 1 with no matches | Decide: no-match OK → grep pattern file || true (commented); no-match bad → keep it failing |
| Cron job overlaps itself on slow nights; double-processing | Run time exceeded the interval; no mutual exclusion | Check for two live PIDs of the job during the window | flock -n preamble (or the crontab-level flock wrapper); log the skips |
| Two simultaneous runs corrupted each other's scratch files | Hardcoded /tmp paths shared between instances | grep the script for literal /tmp/ paths | mktemp/mktemp -d • EXIT trap; unique scratch per run |
| ShellCheck passes locally, scripts in prod still have flagged bugs | Linting is optional/manual — so it happens on some machines, sometimes | Check CI config for a shellcheck step | Gate CI on it; pin the shellcheck version; waivers only with reasons |
E3. 🎓 Capstone — four tickets from the queue
🎓 Ticket 1 — "We added set -euo pipefail to the legacy sync script after last month's incident. It now dies at startup with PROXY_HOST: unbound variable — on the half of the fleet that doesn't use a proxy."
Diagnosis. -u meeting a genuinely optional variable (E2 row 2): the script always read $PROXY_HOST bare, silently empty on proxyless hosts — invisible pre-strictness, fatal now. The strictness isn't wrong; the script never declared the variable optional.
Work the steps: find the bare expansions (grep -n 'PROXY_HOST' sync.sh); confirm the intent (empty = no proxy) with the owners.
Fix and prevention: declare the optionality: proxy="${PROXY_HOST:-}" once at the top, then use "$proxy" (and Module 9's :+ trick shines here: curl ${proxy:+--proxy "$proxy"} … — the flag exists only when the value does). The general migration rule: adopting -u is an audit of which variables are required (:?), defaulted (:-), or presence-flags (:+) — the errors are the audit finding you.
🎓 Ticket 2 — "Strict mode everywhere, and yet: the release script 'succeeded' while its version-fetch clearly failed — the tag came out empty. The line: local version=$(curl -fsS \"$META_URL\" | jq -r .tag)."
Diagnosis. The local masking (B1's second surprise): the substitution's failing verdict (curl's, or jq's) was overwritten by local's own success, so -e saw nothing; version became empty; and everything downstream built artifacts named like app-.tar.gz (Module 9 Ticket 1's cousin). ShellCheck flags exactly this as SC2155 — if it had been in CI, the ticket wouldn't exist.
Work the steps: reproduce: f(){ local v=$(false); echo "alive, v=[$v]"; }; f under set -e — alive. Then the split version — dead, as intended.
Fix and prevention: local version; version=$(curl -fsS "$META_URL" | jq -r .tag) — now the assignment carries the pipeline's verdict (with pipefail covering the jq stage) and -e fires. Add a [ -n "$version" ] || die "empty version" belt to the suspenders, and put ShellCheck in CI so the next masking never merges.
🎓 Ticket 3 — "Since adding pipefail, the nightly log-scan pages on-call about once a week with exit 1 — and every time, it turns out there were simply… no errors in the logs that night. The line: grep ERROR app.log | mail-report."
Diagnosis. E2 row 3 verbatim: grep's documented "searched fine, found nothing" (exit 1 — Module 3's model citizen) now fails the pipeline under pipefail, and "clean logs" became indistinguishable from "scan broke." The paging is the meaning change surfacing, not a malfunction.
Work the steps: confirm the code: on a no-ERROR night, grep ERROR app.log; echo $? → 1; distinguish from a real failure (missing file → 2 — the 1-vs-2 discipline from Module 3 C1).
Fix and prevention: state the intent: grep ERROR app.log || [ $? -eq 1 ] || exit 1 (tolerate exactly no-match, still fail on 2), or the simpler commented || true when "couldn't even search" is acceptable to lump in — teams choose; the comment is mandatory either way. Then fix the alert: page on the scan's own failure, report-not-page on clean nights. Clean nights are the goal, not an incident.
🎓 Ticket 4 — "The certificate-renewal cron overlapped itself during a slow ACME weekend and double-issued certs. The team's proposed fix creates /tmp/renew.lock with [ -f ] check and rm at the end. Review it."
Diagnosis. The hand-rolled lock, both classic holes pre-drilled (Part C's interview answer): check-then-create races (two Saturday runs both pass [ -f ] before either creates), and delete-at-end leaks a stale lock on any kill (Module 13: -9 skips cleanup) — after which no renewal runs until a human notices the certificate expiry graph. The fix would trade an overlap incident for an outage generator.
Work the steps: demonstrate the race conceptually (two interleaved check-then-creates on a whiteboard beats any prose) and the stale-lock path with a kill -9 during a test run.
Fix and prevention: flock: exec 9>/var/lock/renew.lock; flock -n 9 || { log "renewal already active, skipping"; exit 0; } — atomic acquisition, kernel-guaranteed release, stale-proof by construction. Move the lock off /tmp (tmpfiles cleaners delete idle locks) to /var/lock. And log-and-count the skips (E1): a renewal that skips every run is the camouflage incident this module keeps warning about.
E4. Documentation reference
| Topic | Authoritative reference | What you'll find there |
|---|---|---|
| set -e / -u / -x / -o pipefail | Bash manual — The Set Builtin | Each flag's exact semantics — including the complete list of -e's exemptions |
| trap and the ERR condition | Bash manual — Bourne Shell Builtins | trap syntax; ERR's rules and its own guard-position exemptions |
| The forensic variables | Bash manual — Bash Variables | LINENO, BASH_COMMAND, BASH_SOURCE, PIPESTATUS — everything an ERR trap can report |
| Safe scratch space | mktemp(1) — man7.org | File and directory modes, templates, atomic creation guarantees |
| Locking | flock(1) — man7.org | -n and -w modes, the fd idiom, and the canonical self-locking wrappers |
| The linter | ShellCheck | The static analyzer behind every SCxxxx code this track has cited — paste any script |
E5. Self-assessment
Answer from memory, out loud or on paper. Every answer is in this module.
- Recite the strict-mode line and state, one clause each, which module-old hazard each flag retires.
- What is set -e's honest one-sentence contract — and why is "any failure stops the script" wrong?
- List four guard positions where -e looks away, and explain why the design must exempt them.
- Why does set -e; false && true neither stop the script nor fail it?
- Why does local n=$(cmd) defeat both -e and your error checks, and what is the two-line spelling?
- Under -u, how do you write an intentionally optional variable? A required one with a message?
- What changes for grep in pipelines under pipefail, and what are the two honest responses?
- Write the ERR trap that names line, command, and code — and say where its output should go for a cron job.
- Why is mktemp + EXIT trap safer than a hardcoded /tmp path — name both attack/collision problems it closes.
- Give both holes in check-then-create/rm-at-end lock files, and explain how flock closes each.
- When would flock's -w 60 beat -n, and why must skipped-due-to-lock runs be logged and counted?
- Which single practice keeps all of the above present in next year's scripts — and where does it run?
E6. Sources
Zero To Mastery — Bash Interview Prep: 31 Essential Questions and Answers — "How do you handle errors in Bash scripts?", "What are set -u and set -o pipefail in Bash?" (published June 18, 2026)
LinuxTeck — Debugging Shell Scripts & Exit Status Explained (Part 5 of 34) — FAQ: "Why does set -e not work inside an if statement?", "What is the difference between set -e and set -o pipefail?", "How do I log errors to a file in a bash script?" (published April 29, 2026)
Hirist — Top 30+ Shell Scripting Interview Questions — "How can you handle errors in shell scripts?" (published Jul 22, 2025; last modified Dec 31, 2025)
A note on the corpus: strict mode is unusually well-covered in recent published questions (the three sources above all carry verbatim wordings); mktemp and flock circulate as man-page patterns and ops folklore rather than published questions, and the single-instance question above is labeled accordingly. No questions were invented. All expected-result outputs were produced by running the commands on Ubuntu 24.04 / bash 5.2.