Defensive Scripting: set -e, set -u, and pipefail
Three options that make a script fail loudly and immediately instead of silently continuing after something goes wrong — and the real, specific limitations of set -e worth knowing precisely.
What you'll learn
- Explain what set -e actually does, and the specific, documented situations where it does NOT stop a script
- Explain what set -u catches that would otherwise fail silently
- Explain why pipefail is necessary for set -e to correctly catch a failure inside a pipeline
Prerequisites
Explanation
Every real command below runs only in your own terminal — this lesson's exercises model these options' decision logic as data, never executing a real script.
set -e (errexit) makes the script exit immediately the moment any command exits with a non-zero status — instead of the default behavior (continue to the next line regardless), which can otherwise let a script march forward after a real failure and do further damage based on a false assumption that the failed step actually succeeded. This is a genuinely valuable default for most scripts, but it has specific, well-documented exceptions worth knowing precisely, not vaguely: a command's failure inside an if/while condition, or on the left side of &&/||, does not trigger set -e — because in those specific positions, the script is already explicitly checking that command's exit status as part of its own control flow, so set -e correctly assumes you meant to handle the failure yourself there, not have the whole script die.
set -u (nounset) makes referencing an undefined variable an immediate error, rather than Bash's default of silently treating it as an empty string. This precisely catches a common, real class of bug: a typo'd variable name ($FILENAME when the variable was actually set as $FILE_NAME) that would otherwise silently expand to nothing, potentially turning rm "$FILENAME/temp" into the drastically different, dangerous rm "/temp" — set -u turns that into a loud, immediate, safe failure instead of a silent, dangerous one.
set -o pipefail addresses a specific, real gap in how set -e interacts with pipelines: by default, a pipeline's exit code is only the last command's exit code, meaning false | true (the first command genuinely fails) reports overall success, since true — the last command in the pipeline — succeeded. Without pipefail, set -e can completely miss a real failure that happened earlier in a pipeline, as long as the pipeline's final command still succeeded. set -o pipefail fixes this by making the pipeline's exit code reflect the first command in it that failed, if any did — which is exactly why the combination set -euo pipefail at the top of a script (all three options together) is such a common, deliberate, defensive convention, not an arbitrary habit: each option closes a specific, different, real gap the others leave open.
Example
Modeling set -e's real exceptions, set -u's undefined-variable catch, and pipefail's pipeline-exit-code fix, as data.
function wouldSetEStopHere(context) {
// set -e does NOT trigger inside an if/while condition, or on the left of && / ||
// -- these positions are already explicitly checking the exit status themselves.
if (context === "if-condition" || context === "left-of-and-or") {
return false;
}
return true; // a plain, unchecked command failing DOES trigger set -e
}
console.log(wouldSetEStopHere("plain-command")); // true -- script exits immediately
console.log(wouldSetEStopHere("if-condition")); // false -- this is a documented, deliberate exception
function pipelineExitCode(exitCodes, pipefailEnabled) {
if (!pipefailEnabled) {
return exitCodes[exitCodes.length - 1]; // default: only the LAST command's exit code matters
}
return exitCodes.find((code) => code !== 0) ?? 0; // pipefail: first non-zero code, if any
}
console.log(pipelineExitCode([1, 0], false)); // 0 -- WITHOUT pipefail, a real earlier failure is invisible
console.log(pipelineExitCode([1, 0], true)); // 1 -- WITH pipefail, the earlier failure is correctly reportedTry it yourself
Call pipelineExitCode with exitCodes [0, 0, 0] (every command genuinely succeeded), and confirm both modes agree it's a success.
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
This models set -e's real exceptions only -- no real script runs. Write wouldSetEStop(position, exitCode): if exitCode is 0, return false (nothing failed). Otherwise, return false if position is 'if-condition' or 'left-of-and-or' (set -e's documented exceptions); return true for any other position.
Checks: a failing plain command correctly triggers set -e · the if-condition exception is correctly modeled · a successful command never triggers set -e
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
This models pipefail's effect on a pipeline's reported exit code only -- no real pipeline runs. Write firstFailureOrZero(exitCodes) returning the first non-zero code in the array, or 0 if every code is 0 (models set -o pipefail's pipeline exit code).
Checks: reports the first non-zero code, not the last code · reports 0 when every command genuinely succeeded · correctly finds an early failure in the pipeline
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Guided local lab
Build a Defensive Log-Analysis Shell Script
Runs on your computerWrite a real, local Bash script that reads a sample log file and reports error counts, using set -euo pipefail as a defensive foundation — every command below runs in YOUR terminal; this platform does not execute any of them.
Required tools
- A Linux terminal (or macOS Terminal, or WSL on Windows) (any current version)
- Bash (5.x (or a compatible shell))
Setup
- Open a terminal.
- Create a dedicated practice folder: `mkdir -p ~/shell-lab/log-analysis && cd ~/shell-lab/log-analysis`.
- Create a small sample log file (see starter files below) named sample.log in this folder.
Project structure
~/shell-lab/log-analysis/ sample.log analyze.sh
Starter files
sample.log
2026-08-01T09:12:03 INFO service started 2026-08-01T09:12:05 ERROR failed to connect to cache 2026-08-01T09:13:11 INFO request handled 2026-08-01T09:14:02 ERROR timeout waiting for upstream 2026-08-01T09:14:44 WARN retrying request 2026-08-01T09:15:00 ERROR failed to connect to cache
analyze.sh
#!/usr/bin/env bash set -euo pipefail # TODO: accept a log file path as $1 # TODO: fail with a clear message and a non-zero exit code if $1 is missing or the file does not exist # TODO: count ERROR lines and print the count # TODO: print each distinct ERROR message (after the level field) with its occurrence count
Requirements
- analyze.sh begins with set -euo pipefail.
- Running analyze.sh with no arguments prints a clear usage message to stderr and exits with a non-zero code, without set -u causing an unrelated, confusing error first.
- Running analyze.sh with a path to a file that does not exist fails clearly, with a non-zero exit code, instead of silently continuing.
- Running `./analyze.sh sample.log` prints the total number of ERROR lines and a breakdown of each distinct ERROR message with its count.
- The script is made executable with chmod +x before being run as ./analyze.sh.
Commands to run
Make the script executable
chmod +x analyze.shRun with no arguments (should fail with a clear usage message)
./analyze.sh; echo "exit code: $?"Run against a missing file (should fail clearly)
./analyze.sh does-not-exist.log; echo "exit code: $?"Run against the real sample log
./analyze.sh sample.log
Expected behavior
With no arguments, the script prints a usage message and exits non-zero immediately (set -u would otherwise turn a missing $1 into a confusing 'unbound variable' error unless it's checked deliberately first). Against sample.log, it reports 3 total ERROR lines, with 'failed to connect to cache' appearing twice and 'timeout waiting for upstream' appearing once.
Verify it yourself
./analyze.sh; echo $?Expected: prints a clear usage message and a non-zero exit code
./analyze.sh sample.logExpected: reports 3 total ERROR lines
./analyze.sh sample.log | grep 'failed to connect to cache'Expected: shows this message with a count of 2
Troubleshooting
- `analyze.sh: line N: $1: unbound variable` — This is set -u working correctly — add an explicit check (like `if [ "$#" -eq 0 ]; then ... fi`) BEFORE the script tries to use $1, so the failure is a clear, intentional usage message instead of this raw error.
- `Permission denied` when running ./analyze.sh — Run `chmod +x analyze.sh` first — the executable bit must be set before a script can be run directly with ./.
- The script continues even after a command fails — Confirm `set -euo pipefail` is the very first non-comment line, and that the failing command isn't inside an if condition or on the left of && / || (set -e's documented exceptions).
Stuck? Get a hint.
Extension challenge
Add a --since <ISO timestamp> option that only counts ERROR lines at or after the given timestamp, and confirm set -u still catches a missing value for --since immediately, rather than silently comparing against an empty string.
When you've verified this locally, use the "Mark lesson complete" button below to record your progress.
Common mistakes
- Relying on set -e alone and assuming it catches every failure -- it deliberately does NOT stop the script for a failing command inside an if/while condition, or on the left of && / ||, since those positions are already explicitly checking the exit status.
- Forgetting set -o pipefail -- without it, a pipeline's reported exit code is only its LAST command's, silently hiding a real failure earlier in the pipeline as long as the final command still succeeds.
- Adding set -u to an existing script without first checking which variables are genuinely optional -- a variable that's legitimately allowed to be unset needs an explicit default (like ${VAR:-default}), not a blanket removal of set -u.
Knowledge check
Takeaway
set -euo pipefail is a common convention precisely because each option closes a different, specific gap: set -e stops on most unchecked failures (except inside if/while conditions or the left of &&/||), set -u catches undefined-variable typos before they cause silent damage, and pipefail makes a pipeline's exit code reflect its first real failure, not just its last command.
Summary
set -e exits the script on most command failures, with documented exceptions for if/while conditions and the left side of && / ||. set -u turns referencing an undefined variable into an immediate error instead of a silent empty string. set -o pipefail makes a pipeline's exit code reflect its first failing command, not just its last. Together, set -euo pipefail is a deliberate, gap-closing defensive convention.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.