Bash Error Handling: What Happens When You Forget set -e

A deep dive into bash error propagation, trap handlers, and why set -e is not a silver bullet — with testable scripts.

I’ve been writing bash scripts for over a decade. I still get bitten by error handling edge cases. This post tests my understanding of set -e, trap handlers, and pipefail.

The Problem: set -e Is Not Your Safety Net

#!/bin/bash
set -e

echo "Before error"
false
echo "After error"  # This should NOT run with set -e

With set -e, false exits with code 1, so “After error” should not execute. Right?

Wrong. In some contexts, set -e does NOT propagate. Let me test my understanding:

Where set -e Fails

#!/bin/bash
set -e

# Context 1: Inside a conditional
if false; then
  echo "This never runs"
fi
echo "After conditional"  # This DOES run — set -e is suppressed inside conditions

# Context 2: Right side of || or &&
false || echo "Fallback"  # Suppressed
echo "After OR chain"

# Context 3: Command substitution
result=$(false)
echo "After subshell: $?"  # Subshell exit code is 1, but main script continues

The key insight: set -e is “inactive” inside conditionals, ||/&& chains, and subshells. This is by design, but it’s surprising.

Proper Error Handling Pattern

#!/bin/bash
set -euo pipefail

ERROR_COUNT=0

cleanup() {
  local exit_code=$?
  echo "Cleanup: exit code $exit_code, $ERROR_COUNT errors"
  rm -f /tmp/tempfile_*
}

trap cleanup EXIT

error_handler() {
  local line=$1
  local cmd=$2
  local code=$3
  ERROR_COUNT=$((ERROR_COUNT + 1))
  echo "[ERROR] Line $line: '$cmd' failed with code $code" >&2
}

trap 'error_handler $LINENO "$BASH_COMMAND" $?' ERR

do_work() {
  local stage=$1
  echo "Stage $stage"
  # Simulate work
  sleep 0.1
  return $((stage % 3))  # Return 0, 1, or 2
}

for i in 1 2 3; do
  do_work "$i" || true  # Don't let failures propagate, we count them
done

echo "Pipeline complete with $ERROR_COUNT errors"
exit $((ERROR_COUNT > 0 ? 1 : 0))

Edge Case: set -e and Functions

#!/bin/bash
set -e

failing_func() {
  false
  echo "This should NOT run"
}

# This exits because set -e is ON inside the function
# failing_func

# But this does NOT exit:
result=$(failing_func)  # Subshell swallows the error
echo "Subshell result: $result"  # Empty, but script continues

A function called directly respects set -e. A function called in a subshell ($()) does not — the subshell exits, but the parent continues.

The Pipefail Pattern

#!/bin/bash
set -euo pipefail

# Without pipefail: exit code is from last command in pipe
# With pipefail: exit code is from first non-zero exit in pipe

generate_data() {
  echo "data1"
  echo "data2"
  return 1
}

# Without pipefail, this would "succeed" because head exits 0
if ! generate_data | head -n 1 > /dev/null; then
  echo "Caught pipe failure"  # With pipefail, this runs
fi

What I Got Wrong

  1. trap ... ERR fires regardless of set -e — even inside conditionals, the ERR trap fires. I thought it required set -e to be active.
  2. trap ... EXIT runs on ALL exits — even kill -9? No, SIGKILL can’t be trapped. But normal exits, exit calls, and signals like SIGINT all trigger EXIT.
  3. set -u doesn’t catch all unset variables${var-default} and ${var:-default} are fine. But ${var} with no set -u silently produces empty string, which can cause rm -rf / $unset_var disasters.

Score: 7/10 — strong on basic patterns, weak on subshell interaction with set -e.