open atlas
↑ Back to track
Command line CLI · 08 · 04

Robust scripts

set -euo pipefail turns bash into a fail-fast interpreter: -e exits on any error, -u treats unset variables as errors, -o pipefail catches failures inside pipelines. trap cleans up on exit. Functions with local variables prevent variable leakage.

CLI Senior ◷ 25 min
Level
FoundationsJuniorMiddleSenior

A bash script without set -euo pipefail is a script that silently continues past failures. A command that returns exit code 1 is ignored. A typo in a variable name expands to an empty string. A failed command inside a pipeline is masked by the last command’s success. The script prints “Done!” and leaves a half-finished, corrupt, or overwritten state behind. Three flags and one trap call flip this entirely: bash becomes a strict, fail-fast interpreter that stops at the first sign of trouble and runs your cleanup code before it exits. These are not optional polish — they are the difference between a script you can trust in production and one you babysit.

Goal

After this lesson you can write a correct shebang line, apply set -euo pipefail and explain what each flag does, register cleanup with trap, write functions that use local to avoid variable leakage, and know the three common set -e gotchas.

1

#!/usr/bin/env bash is the correct shebang for portable bash scripts. It asks the OS to run whichever bash appears first in $PATH, rather than hardcoding /bin/bash (which does not exist on some systems, notably NixOS and some containers).

#!/usr/bin/env bash
# Always the first line — no blank lines above it

/bin/sh invokes the system POSIX shell, which may be dash, ash, or busybox — not bash. If your script uses [[ ]], arrays, or any bash-specific feature, it must use #!/usr/bin/env bash. Running a bash script under sh produces cryptic parse errors for constructs that are valid bash but not POSIX sh.

2

set -e exits the script immediately if any command returns a non-zero exit code. Without it, every failed command is silently ignored and execution continues.

set -e
cp /nonexistent /tmp/   # exits here — cp fails
echo "This never runs"  # skipped

set -u treats any reference to an unset variable as an error and exits.

set -u
echo "$UNDEFINED_VAR"   # exits: bash: UNDEFINED_VAR: unbound variable

This catches the typo-variable bug: $TEMP_DIR vs $TMPDIR — one is set, one is not, and without -u the unset one silently expands to an empty string, causing rm -rf "" or cd "" to do something unexpected.

set -o pipefail makes a pipeline fail if any stage fails, not just the last one.

set -o pipefail
grep "pattern" missing_file.txt | wc -l
# Without pipefail: exits 0 (wc succeeded)
# With pipefail:    exits non-zero (grep failed)

Put all three together at the top of every script:

set -euo pipefail
3

trap 'cleanup_cmd' SIGNAL registers a command to run when the script receives a signal or exits. The most useful signal is EXIT — it fires on any exit, including errors.

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

TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT   # always cleaned up, even on error

# ... script body uses $TMPDIR ...
cp important_data.txt "$TMPDIR/"
process "$TMPDIR/important_data.txt"

Without the trap, a failure midway through leaves $TMPDIR on disk. With it, bash runs rm -rf "$TMPDIR" before the process exits regardless of how it exits — normal completion, exit 1, or an uncaught error.

Multiple signals: trap 'cleanup' EXIT INT TERM also handles Ctrl-C (INT) and kill (TERM).

4

Functions encapsulate reusable logic. local prevents variables from leaking into the caller’s scope.

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

log() {
  local level="$1"
  local msg="$2"
  echo "[$(date +%T)] [$level] $msg" >&2
}

backup_file() {
  local src="$1"
  local dest="${src}.bak.$(date +%s)"
  cp "$src" "$dest"
  log INFO "Backed up $src to $dest"
}

backup_file /etc/nginx/nginx.conf

Without local, level, msg, src, dest are global variables. If a calling function also uses a variable named dest, the inner function overwrites it. local scopes the variable to the function’s stack frame. This is not optional style — it prevents action-at-a-distance bugs in scripts longer than a few functions.

Functions return exit codes like commands do. return 0 is success; return N is failure. The last command’s exit code is the function’s exit code if you don’t call return explicitly.

5

Three common set -e gotchas that trip up experienced engineers.

Gotcha 1: commands in if conditions are exempt. set -e does not fire when a command is used directly as a condition.

set -e
if grep -q "pattern" file.txt; then echo "found"; fi  # grep exit 1 = not found, no abort
grep -q "pattern" file.txt   # this WILL abort on exit 1

Gotcha 2: the last command in a pipeline determines pipefail. With pipefail, the pipeline’s exit code is the rightmost non-zero exit code. If multiple stages fail, only the last non-zero is reported.

Gotcha 3: local itself is a command with an exit code. This is a subtle trap:

set -e
get_value() {
  local val=$(some_command_that_fails)  # WRONG: local returns 0 even if $() fails
  local val
  val=$(some_command_that_fails)        # CORRECT: assignment separate from declaration
}

local val=$(cmd) evaluates cmd, assigns to val, then local runs and returns 0 regardless — swallowing the failure. Declare local first, then assign on a separate line.

Worked example

A production-grade deploy script skeleton.

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

DEPLOY_DIR="/var/app"
RELEASE_DIR="${DEPLOY_DIR}/releases/$(date +%Y%m%d_%H%M%S)"
CURRENT_LINK="${DEPLOY_DIR}/current"

log()  { echo "[$(date +%T)] $*" >&2; }
die()  { log "ERROR: $*"; exit 1; }

cleanup() {
  local code=$?
  if [[ $code -ne 0 ]]; then
    log "Deploy failed (code $code), rolling back"
    [[ -L "$CURRENT_LINK" ]] && log "Current symlink intact"
    rm -rf "$RELEASE_DIR" 2>/dev/null || true
  fi
}
trap cleanup EXIT

[[ $# -eq 1 ]] || die "Usage: $0 <tarball>"
[[ -f "$1"  ]] || die "File not found: $1"

mkdir -p "$RELEASE_DIR"
tar -xzf "$1" -C "$RELEASE_DIR"
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
log "Deploy complete: $RELEASE_DIR"

Every variable is local inside functions. trap cleanup EXIT runs on any failure. set -euo pipefail ensures tar or ln failure aborts immediately rather than creating a half-deployed release. die() centralises error reporting.

Why this works

On macOS bash 3.2, set -e, set -u, and set -o pipefail all work. The macOS gotcha is local val=$(cmd) — same bug, same fix as on Linux. One additional macOS note: mktemp -d on macOS requires no template argument (it works with just mktemp -d), whereas GNU mktemp also accepts a template. Both forms mktemp -d and mktemp -d /tmp/prefix.XXXXXX work on Linux; only mktemp -d works portably on both.

Common mistake

A dangerous pattern: set -e combined with cd followed by rm -rf. If cd "$TARGET_DIR" fails (wrong path, permissions) and set -e is absent, the next rm -rf * runs in whatever directory the script is currently in — potentially deleting the wrong tree. With set -e, the script exits at the failed cd. Belt-and-suspenders form: cd "$TARGET_DIR" || { echo "cd failed" >&2; exit 1; } — explicit even with set -e, because it makes the intent obvious to the next reader.

Check yourself
Quiz

In a script with set -euo pipefail, you write: local result=$(failing_command). What happens?

Recap

#!/usr/bin/env bash picks the correct bash from $PATH instead of hardcoding /bin/bash. set -e exits on any non-zero exit code; set -u exits on unset variable reference; set -o pipefail makes pipeline failures visible — use all three, always. trap 'cleanup' EXIT guarantees cleanup code runs regardless of how the script exits. local scopes function variables to prevent cross-function contamination. The local val=$(cmd) trap: declare local and assign separately so set -e can catch the command’s failure. These four practices transform bash from a fragile sequence of commands into a reliable, auditable automation tool.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.