NAME
Safer shell scripts, strict mode and quoting
What set -euo pipefail actually does, where it stays silent, how to quote properly, and a trap that cleans up after itself.
SYNOPSIS
DESCRIPTION
Bash keeps going after errors by default. A command fails, the next line runs anyway, and you end up with an empty file uploaded or the wrong directory deleted. A few settings at the top of a script stop most of that. The rest comes down to habits.
What set -euo pipefail does
Put this right after the shebang:
set -euo pipefail
-e(errexit) exits the script when a command fails.-u(nounset) treats a reference to an unset variable as an error instead of an empty string.-o pipefailmakes a pipeline fail if any command in it fails, not only the last one.
Each of these has gaps.
Where set -e does not help
-e is ignored in some contexts: the condition of an if, while or until, the left side of && or ||, and any command prefixed with !. Bash does this on purpose, so this line keeps running:
set -e
false || echo "still running"
Declaring and assigning on one line also hides the exit status, because local itself succeeds:
local out=$(false) # failure is lost
local out
out=$(false) # failure stops the script
pipefail has a common trap with grep. grep exits 1 when nothing matches, so under pipefail a "no match" becomes a pipeline failure. If no match is a normal result, say so:
count=$(grep -c "ERROR" app.log || true)
For optional variables, use the :- default so -u does not fire:
if [[ -n ${DEBUG:-} ]]; then
set -x
fi
Quote every expansion
An unquoted $var is split on spaces and tabs, and any glob characters are expanded. Quoting stops both:
file="my notes.txt"
cat $file # tries to open "my" and "notes.txt"
cat "$file" # correct
Use "$@" to pass arguments through. It keeps each argument as its own word. "$*" joins everything into one string, which is almost never what you want:
wrapper() {
printf '[%s]\n' "$@"
}
wrapper "a b" c # prints [a b] and [c]
For lists of things that may contain spaces, use an array:
files=("my notes.txt" "report 2026.pdf")
for f in "${files[@]}"; do
echo "$f"
done
Clean up with trap
Create temp space with mktemp -d and remove it on exit:
tmp=$(mktemp -d)
trap 'rm -rf "${tmp:?}"' EXIT
The single quotes matter. $tmp is expanded when the trap fires, not when it is set. The :? guard makes the expansion fail if tmp is empty. Without it, rm -rf "$tmp/cache" with an empty tmp becomes rm -rf /cache, and you do not want to find out why.
The EXIT trap runs on a normal exit and on a set -e failure, so the cleanup happens either way.
Run shellcheck
shellcheck catches most of the mistakes above. Install it from your package manager (apt install shellcheck, dnf install ShellCheck, brew install shellcheck), then run:
shellcheck script.sh
SC2086 means an unquoted expansion. SC2155 means declare-and-assign on one line. Fix the warning first. Only add a # shellcheck disable= comment when you have a reason, and write the reason next to it.
A complete example
This script takes a pattern and a list of files, prints the files that match, and cleans up its temp directory on exit:
#!/usr/bin/env bash
set -euo pipefail
[[ $# -ge 2 ]] || { echo "usage: ${0##*/} PATTERN FILE..." >&2; exit 2; }
pattern=$1
shift
tmp=$(mktemp -d)
trap 'rm -rf "${tmp:?}"' EXIT
report="$tmp/matches.txt"
for file in "$@"; do
[[ -r $file ]] || { echo "cannot read: $file" >&2; exit 1; }
if grep -q -- "$pattern" "$file"; then
printf '%s\n' "$file" >> "$report"
fi
done
if [[ -s $report ]]; then
cat "$report"
else
echo "no matches"
fi