Skip to content

Lesson 1 of 9 · 15m

set -euo pipefail: the First Five Lines

What errexit, nounset, and pipefail actually do, where set -e quietly does nothing, and the local bug.

You inherit a deploy script that prints "done" no matter what happened inside it. The last outage it caused traced back to a grep that failed halfway through — the script sailed on, applied a partial config, and left the box in a state nobody could reproduce. This lesson is the five minutes of setup that prevents that entire class of failure.

Strict mode: the only sane default

Every Bash script you write should start like this:

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

Three switches, three different problems:

  • set -e (errexit): the shell exits when a command returns non-zero. This is the single biggest behavioral change — a failing command no longer gets silently stepped over.
  • set -u (nounset): expanding an unset variable is an error, not an empty string. rm -rf "$BULID_DIR" stops dead because you typo'd BUILD_DIR — instead of evaluating to rm -rf "" or worse.
  • set -o pipefail: a pipeline returns the rightmost non-zero exit, not just the last command's. Without it, grep -q token /etc/hosts | head -1 reports success because head succeeded, even when grep failed. With it, the pipeline propagates grep's status.

pipefail is not POSIX. It works in Bash (3.x+), zsh, and ksh — but not in dash, which is what /bin/sh points to on Debian and Ubuntu. If your script relies on it, say #!/usr/bin/env bash and mean it. Never assume sh is Bash.

Where set -e quietly does nothing

Errexit is disabled inside if, while, and until conditions, on the left side of &&/||, and under !. That's by design — the condition needs to see the exit code:

if grep -q 'listen 443' /etc/nginx/nginx.conf; then
  echo "tls site configured"
fi

The trap that ships to production: local. local is a builtin, and it masks the command's failure:

f() {
  local out=$(cat /nonexistent)   # set -e does NOT catch this
  echo "$out"
}

local exits 0, so errexit stays quiet. Write it on two lines:

local out
out=$(cat /nonexistent)   # now set -e fires correctly

Also know that set -e inside command substitution ($(...)) behaves subtly — a failing command inside $( ) does not kill the script; it just sets $?. Check it when it matters.

Debugging with tracing

set -x traces every command before it runs. Make the trace useful:

export PS4='+ ${BASH_SOURCE}:${LINENO}: '

Now every trace line tells you which file and line it came from — invaluable inside functions.

Try this

Run this twice — once with set -o pipefail, once without:

#!/usr/bin/env bash
set -euo pipefail
grep -q 'zzz' /etc/hosts | head -1
echo "made it"

Without pipefail, you reach "made it". With it, the script exits at the pipeline. That's the difference between a silent skip and a caught failure.

Field notes

  • grep returning 1 on "no matches" is a feature — use grep -q ... || true when "not found" is a legitimate outcome, not a failure.
  • set -u will bite you on ${1:?usage: $0 <arg>}-style checks — that's intentional. Provide defaults with ${var:-default}.
  • Never eval script input. If you think you need it, you want an array (covered in lesson 3).