Error Handling & the Limits of Strict Mode

Why `set -euo pipefail` is necessary, why it is not sufficient, and the specific constructs where it silently stops protecting you.

intermediate 19 min lesson hands-on task included

Every serious bash guide opens with set -euo pipefail, and every serious bash guide is right. What most of them skip is the list of places those flags quietly stop applying — which is exactly where the bugs live.


Topic 1: The Three Flags

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
FlagLong formEffect
-eset -o errexitExit immediately if a command returns non-zero
-uset -o nounsetTreat an unset variable as a fatal error
-o pipefailA pipeline fails if any stage fails, not just the last

What each one actually buys you:

-u is the least controversial and arguably the most valuable. Without it, a typo’d variable expands to empty:

rm -rf "$BULID_DIR/"      # typo -- expands to "/" without -u

That is not a hypothetical; it is the canonical shell disaster. With -u the script dies before the rm.

pipefail closes the hole where a pipeline’s failure is masked by a successful last stage:

curl -f https://api/data | jq .name        # curl 404s, jq succeeds on empty, exit 0

-e is the one with caveats, and they occupy the rest of this lesson.

The IFS line:

Setting IFS=$'\n\t' removes space from the field separator, so unquoted expansions split only on newlines and tabs. It is a safety net for code you did not write. It is not a substitute for quoting — quote anyway, and treat this as defence in depth.


Topic 2: Where set -e Stops Applying

This is the substance. set -e is disabled — deliberately, by POSIX — in every context where a non-zero status is being tested rather than being an error.

1. Anything in a condition:

set -e
if failing_command; then ... fi        # does NOT exit
while failing_command; do ... done     # does NOT exit
failing_command && echo ok             # does NOT exit
failing_command || echo fallback       # does NOT exit
! failing_command                      # does NOT exit

This is necessary — if would be useless otherwise — but it means wrapping code in a condition silently disables the protection inside it.

2. Any command that is not the last in a chain:

set -e
false && true          # whole line succeeds, no exit

3. Inside functions called from a condition — the subtle one:

set -e
check() {
    false              # under normal call: exits here
    echo "unreachable"
}

check                  # script exits ✓
if check; then :; fi   # does NOT exit -- the WHOLE function runs with -e suspended

Calling a function from an if disables errexit for everything inside it, recursively. A validation function that relies on set -e to abort becomes a function that runs to completion and returns the status of its last line.

4. Arithmetic that evaluates to zero:

The classic. (( )) returns exit status 1 when the expression evaluates to 0:

set -e
i=0
(( i++ ))              # post-increment RETURNS 0 → exit status 1 → SCRIPT EXITS
echo "never printed"

Fixes, in order of preference:

(( ++i ))              # pre-increment returns 1 -- fine, but subtle
(( i++ )) || true      # explicit, obvious
i=$(( i + 1 ))         # assignment always succeeds -- clearest
let "i++" || true

The same trap applies to any (( )) whose value can be zero: (( count = 0 )), (( flag & mask )).

5. Command substitution in an assignment:

set -e
out=$(failing_command)          # exits ✓ (bash propagates the status)
export out=$(failing_command)   # does NOT exit -- `export` succeeded
local out=$(failing_command)    # does NOT exit -- `local` succeeded

local and export are commands in their own right, and their exit status is what counts. Always split the declaration from the assignment:

local out
out=$(failing_command)          # now it exits correctly

This one is easy to miss and ShellCheck flags it as SC2155.


Topic 3: Handling Expected Failures

set -e is about unexpected failures. Anything you expect to fail needs to say so.

grep -q pattern file || true              # a non-match is not an error
grep -q pattern file || :                 # `:` is the null command, same thing

if grep -q pattern file; then             # better: test it explicitly
    handle_match
fi

# Capture a status without dying
set +e
risky_command
status=$?
set -e

The || true idiom is fine and idiomatic, but it discards the status entirely. When you need the code, capture it:

status=0
risky_command || status=$?
if (( status == 2 )); then
    echo "specifically the 'not found' case"
fi

Topic 4: trap ERR — Knowing What Failed

set -e exits, but silently. An ERR trap turns that into a diagnosable event.

#!/usr/bin/env bash
set -Eeuo pipefail

on_error() {
    local exit_code=$?
    local line=${BASH_LINENO[0]}
    local cmd=${BASH_COMMAND}
    echo "ERROR: line ${line}: '${cmd}' exited ${exit_code}" >&2
    exit "$exit_code"
}
trap on_error ERR
VariableHolds
$?The failing exit status (read it first — anything else overwrites it)
$BASH_COMMANDThe command that failed, as text
${BASH_LINENO[0]}The line number
${FUNCNAME[@]}The function call stack
${BASH_SOURCE[@]}The file stack, for sourced libraries

The -E flag is required:

Without set -E (errtrace), the ERR trap is not inherited by functions, subshells, or command substitutions — so it never fires for the code most likely to fail. set -Eeuo pipefail is the complete form and worth adopting as the default.

A stack trace:

on_error() {
    local code=$?
    echo "FATAL: exit ${code} at ${BASH_SOURCE[1]}:${BASH_LINENO[0]}" >&2
    local i=0
    while caller $i; do ((i++)) || true; done >&2
    exit "$code"
}
trap on_error ERR

caller walks the call stack one frame per invocation. On a 300-line script with nested functions this is the difference between “it failed” and “it failed in upload_artifact, called from deploy, at line 212”.


Topic 5: Cleanup with trap EXIT

EXIT fires on every exit path — success, failure, set -e abort, or an interrupt you have also trapped. It is the right place for cleanup.

#!/usr/bin/env bash
set -Eeuo pipefail

WORKDIR=$(mktemp -d)
cleanup() {
    local code=$?
    rm -rf "$WORKDIR"
    (( code != 0 )) && echo "failed with ${code}, workdir removed" >&2
    return "$code"
}
trap cleanup EXIT
trap 'exit 130' INT       # Ctrl+C → exit 130 → EXIT trap still runs
trap 'exit 143' TERM

Trapping INT and TERM to call exit rather than doing the cleanup directly means you write the cleanup once, in the EXIT handler, and every path routes through it.

Common mistake: trap cleanup EXIT INT TERM. On Ctrl+C this runs cleanup for INT and then again for EXIT — a double free. Route signals to exit and let EXIT do the work.


Topic 6: Making Failures Legible

An exit code is not an incident report. Three habits make a failing script diagnosable by someone who is not you at 3am:

#!/usr/bin/env bash
set -Eeuo pipefail

readonly SCRIPT_NAME=${0##*/}

die()  { echo "${SCRIPT_NAME}: FATAL: $*" >&2; exit 1; }
warn() { echo "${SCRIPT_NAME}: WARN: $*"  >&2; }
info() { echo "${SCRIPT_NAME}: $*"; }

# 1. Validate inputs up front, with a specific message per failure
[[ -n "${TARGET_HOST:-}" ]] || die "TARGET_HOST is not set"
[[ -r "$CONFIG" ]]          || die "config not readable: ${CONFIG}"
command -v rsync >/dev/null || die "rsync is not installed"

# 2. Errors and diagnostics go to stderr, data goes to stdout
info "starting sync to ${TARGET_HOST}"

# 3. Distinct exit codes for distinct failure modes
readonly E_CONFIG=2 E_NETWORK=3 E_VERIFY=4
ping -c1 -W2 "$TARGET_HOST" >/dev/null 2>&1 || exit "$E_NETWORK"

Distinct exit codes matter because the caller — a CI job, a cron wrapper, a retry loop — can act on them. “Exit 3 means the network was unreachable, retry” is a policy you can implement; “exit 1” is not.

Try it yourself: Take an existing script, add set -Eeuo pipefail and an ERR trap, and run it. Fix whatever it now reports — there will be something, and it was already broken.

Common mistake: Adding set -e to a long-lived script and assuming it is now safe. It changes behaviour: commands that were failing harmlessly now abort the run. Add it, then read the whole script for the contexts above before trusting it.