advanced21 min

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 reported

Try 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.

Loading editor…

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.

Loading editor…

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.

Loading editor…

Stuck? Get a hint.

Guided local lab

Build a Defensive Log-Analysis Shell Script

Runs on your computer
This lab runs on your own computer, in your own terminal and editor — not in your browser. VisaSparkSchools does not execute, run, or verify these commands for you. Follow the verification steps yourself to confirm your result.

Write 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

  1. Open a terminal.
  2. Create a dedicated practice folder: `mkdir -p ~/shell-lab/log-analysis && cd ~/shell-lab/log-analysis`.
  3. 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.sh
  • Run 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.log

    Expected: 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.shRun `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 failsConfirm `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

Knowledge check

1. Which of these does set -e deliberately NOT stop the script for?
2. Without set -o pipefail, what exit code does a pipeline like `false | true` report?
3. What specific bug does set -u catch that would otherwise fail silently?

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.