The traps in set -euo pipefail

Bash strict mode is a good default, but -e has exceptions that surprise everyone at least once. The cases that bit me, with fixes.

Alex Novak 5 min read
On this page

Almost every bash script I write starts with the same line:

bash
set -euo pipefail

What each flag does:

  • -e exits on the first failing command;
  • -u treats unset variables as errors;
  • -o pipefail makes a pipeline fail if any command in it fails, not just the last one.

I still recommend it. But I’ve watched people (me included) assume it makes bash behave like a language with exceptions, and it doesn’t. Here are the cases that have actually bitten me.

1. -e is off inside conditions#

set -e is ignored for any command that’s part of an if condition, a while condition, or the left side of && and ||. That’s by design, otherwise if grep -q foo file would kill your script. The surprise is that it applies to everything called from there, including whole functions:

deploy.shbash
set -e

deploy() {
  cp build/app /srv/app      # fails...
  systemctl restart app      # ...but this still runs
  echo "deployed"
}

if deploy; then
  echo "ok"
fi

Because deploy runs as an if condition, errexit is disabled for its entire body. The cp fails, the restart happens anyway, and you get “ok”. Same with deploy || echo "failed".

Fix: don’t rely on -e inside functions you call in a condition. Either check explicitly:

bash
deploy() {
  cp build/app /srv/app || return
  systemctl restart app || return
}

or call the function on its own line and inspect $? afterwards.

2. local swallows the exit code#

bash
get_version() {
  local version=$(curl -fsS https://example.com/version)
  echo "$version"
}

If curl fails, the script keeps going: the exit status of that line is the status of local, which succeeded. export and readonly behave the same way.

Fix: split the declaration and the assignment.

bash
get_version() {
  local version
  version=$(curl -fsS https://example.com/version)
  echo "$version"
}

3. Command substitution doesn’t inherit -e#

In bash, subshells created by $(...) don’t inherit errexit unless you ask for it:

bash
set -e
result=$(false; echo "still running")
echo "$result"   # prints "still running"

The assignment as a whole takes the exit status of the substitution, and since echo succeeded, nothing fails.

Fix (bash 4.4+):

bash
shopt -s inherit_errexit

I add it to my strict mode header on every system where I know bash is new enough.

4. Arithmetic that evaluates to zero#

bash
set -e
count=0
((count++))
echo "never printed"

((expr)) returns status 1 when the expression evaluates to 0. Post-increment returns the old value, 0, so the script exits on the very first increment.

Fix: use plain assignment.

bash
count=$((count + 1))

5. pipefail and SIGPIPE#

bash
set -o pipefail
first=$(generate-big-report | head -n 1)

head reads one line and exits. The next time generate-big-report writes, it gets SIGPIPE and dies with status 141. With pipefail that turns the whole pipeline into a failure, and with -e your script exits. Whether it happens depends on output size and timing, so it’s flaky in exactly the way you don’t want.

A related one: grep exits with 1 when it finds nothing, so grep ERROR app.log | wc -l fails on a clean log. Options:

bash
errors=$(grep -c ERROR app.log || true)       # -c counts, || true accepts "zero"
first=$(generate-big-report | head -n 1) || true

Be deliberate about || true; it hides real failures too. For grep specifically, I’d rather check for status 1 and let 2 (a real error, like a missing file) through:

bash
grep -q ERROR app.log && status=0 || status=$?
if (( status > 1 )); then
  echo "grep failed" >&2
  exit "$status"
fi

6. -u and optional values#

set -u catches typos in variable names, which is great. It also fires on legitimately optional values like positional arguments and environment variables. Use defaults:

bash
target=${1:-staging}
debug=${DEBUG:-0}

On bash older than 4.4, expanding an empty array with "${files[@]}" under -u also fails with “unbound variable”. If you still support old systems (hello, CentOS 7 boxes nobody wants to touch), ${files[@]+"${files[@]}"} works everywhere, though it’s ugly enough that I’d rather bump the bash requirement.

Getting line numbers on failure#

When a strict script dies, the default message tells you nothing. An ERR trap helps:

bash
set -Eeuo pipefail
trap 'echo "error: line $LINENO: $BASH_COMMAND (exit $?)" >&2' ERR

Note the -E: without it the ERR trap isn’t inherited by functions, command substitutions or subshells, so failures inside functions go unreported.

My header#

strict-header.shbash
#!/usr/bin/env bash
set -Eeuo pipefail
shopt -s inherit_errexit
trap 'echo "error: line $LINENO: $BASH_COMMAND (exit $?)" >&2' ERR
TrapSymptomFix
function in a conditionkeeps going after a failureexplicit || return
local x=$(cmd)failure ignoreddeclare, then assign
$(...)-e not inheritedshopt -s inherit_errexit
((count++)) from 0script exitscount=$((count + 1))
| head, grep no matchrandom or false failureshandle status explicitly
optional args under -u“unbound variable”${1:-default}

Strict mode is a safety net with holes in it. It catches the common case, the command that fails in the middle of a script. The rest is on me: ShellCheck, explicit checks in functions, and keeping scripts short enough that I can still reason about every exit code.