StackKit

Writing Safer Bash Scripts: set -euo pipefail, Quoting and trap

By · · 4 min read

Bash is forgiving by default. A failed command does not stop the script, an unset variable silently becomes an empty string, and an unquoted filename with a space is split into two arguments. That is convenient in a terminal and dangerous in a script, where an empty variable inside rm -rf "$DIR"/ can delete the wrong thing.

This article covers a small set of habits that make scripts stop at the first real problem. The behaviour shown below was run with Bash on macOS.

Start with strict mode

Put this near the top of every script:

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

It turns on three options. Each is worth understanding.

set -e: exit when a command fails

bash -c 'set -e; false; echo not-reached'
echo "exit=$?"
# exit=1

The script stopped at false and not-reached was never printed. Without -e, the script would carry on as if nothing had happened.

-e has exceptions: it does not trigger for commands in an if condition, in && / || lists (except the last one), or in a while test. So it is a safety net, not a replacement for checking results you care about.

set -u: error on unset variables

bash -c 'set -u; echo "$UNSET_VAR"'
# bash: UNSET_VAR: unbound variable

A typo in a variable name is now an error rather than an empty string. When a variable may legitimately be unset, give it a default with ${VAR:-default}.

set -o pipefail: a pipeline fails if any part fails

By default a pipeline returns the exit status of its last command only:

bash -c 'false | cat; echo "without pipefail: $?"'
# without pipefail: 0

bash -c 'set -o pipefail; false | cat; echo "with pipefail: $?"'
# with pipefail: 1

Without pipefail, a failing first stage is hidden, so curl ... | tar xz can "succeed" even when the download failed.

Quote your variables

Unquoted expansions are split on whitespace and expanded as globs. Compare:

f="my notes.txt"
printf "[%s]\n" $f      # unquoted
printf "[%s]\n" "$f"    # quoted
[my]
[notes.txt]
[my notes.txt]

The unquoted version passed two arguments. With a command like rm, that means deleting two different files. The rule: always write "$var" unless you specifically want splitting. The same applies to "$@" for passing along all arguments, and "${array[@]}" for arrays.

Validate arguments early

Fail with a clear message and a non-zero exit code when the input is wrong:

name="${1:-}"
[[ -n "$name" ]] || { echo "usage: $0 NAME" >&2; exit 2; }

Errors go to standard error (>&2), so they do not mix with output that other programs may be reading. By convention, exit code 2 means a usage error and 1 a general failure.

Clean up with trap

Scripts that create temporary files should remove them even when something fails. trap ... EXIT runs a command when the script exits for any reason.

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

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"; echo "cleaned up"' EXIT

name="${1:-}"
[[ -n "$name" ]] || { echo "usage: $0 NAME" >&2; exit 2; }

file="$tmp/my notes.txt"
echo "hello $name" > "$file"
cat "$file"

Running it with and without an argument:

$ ./script.sh Ada
hello Ada
cleaned up

$ ./script.sh
usage: ./script.sh NAME
cleaned up

In both cases the temporary directory is removed. Note that the trap line is written before anything can fail, and the path is quoted. Using mktemp -d also avoids predictable file names in /tmp.

Other useful habits

  • Use [[ ... ]] instead of [ ... ] in Bash. It does not word-split variables and supports pattern matching.
  • Prefer $(command) over backticks. It nests cleanly and is easier to read.
  • Use mktemp for temporary files, never a fixed name.
  • Avoid parsing ls. Use globs (for f in *.txt) or find ... -print0 with read -d ''.
  • Check dependencies with command -v tool >/dev/null || { echo "tool missing" >&2; exit 1; }.
  • Run ShellCheck. It is a free static analyser (shellcheck script.sh) that catches most quoting and syntax mistakes and explains them.

When strict mode is not enough

set -e can produce surprises (for example inside functions called from conditions), and pipefail makes grep -q pipelines fail when the reader exits early. If a script grows beyond a page or two, or needs real data structures, consider a language such as Python, where errors are exceptions by default.

Summary

Start every script with set -euo pipefail, quote every expansion, validate arguments, and clean up with trap. Four habits, a few lines each, and most "it silently did the wrong thing" bugs disappear.