set Options
set -euo pipefail, IFS, set -x — the four-line opener that turns a fragile bash script into one you can trust.
set Options
Bash's default behaviour is permissive to the point of dangerous: failed commands are ignored, undefined variables expand to empty strings, pipeline failures are silently swallowed. The set builtin lets you opt in to stricter behaviour. The standard incantation is set -euo pipefail, and you should put it at the top of every script you write.
The four-line opener
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
trap '<cleanup>' EXIT
Take it apart:
#!/usr/bin/env bash— find bash on PATH, not hardcoded/bin/bash(which is bash 3.2 on macOS).set -e— abort on the first non-zero exit (with caveats).set -u— error on use of an unset variable.set -o pipefail— pipeline exit code is the first non-zero, not the last.IFS=$'\n\t'— safer field separator (drops space).trap '...' EXIT— cleanup on every exit path.
Type these four lines without thinking. Every script you'd want to commit starts with them.
set -e (errexit) — and its surprising exemptions
set -e aborts when any command exits non-zero, except:
- The condition of
if,while,until(otherwise you couldn't test for failure). - The left side of
&&or||(otherwise short-circuit wouldn't work). - The body of a function called via
||(e.g.func || cleanup). - Inside a pipeline (only the LAST command's exit matters — that's why you also need
pipefail). - Negated commands (
! cmd).
This means set -e alone is not a safety net. You also need pipefail, and you need to know the exemptions.
set -e
false && echo "skipped" # `false` doesn't abort — left side of &&
echo "$?" # 1 — exit code is preserved but we didn't abort
if false; then ...; fi # `false` doesn't abort — condition of if
When you genuinely want a non-zero exit to be ignored:
grep PATTERN file || true # match-or-not, never abort
mkdir -p "$dir" || : # `:` is the no-op builtin
some_check || handle_failure
Use these explicitly. Don't disable set -e to "make the script work" — fix the failures, or annotate them with || true.
set -u (nounset) — undefined-variable check
set -u
echo "$undefined" # bash: undefined: unbound variable; exit 1
Catches typos at runtime (the closest bash gets to a type system). Has friction with optional variables — use the ${var:-default} form to provide a fallback:
set -u
echo "${LOG_LEVEL:-info}" # works — uses "info" if LOG_LEVEL unset
echo "${LOG_LEVEL}" # errors if unset
For optional positional args inside a function:
set -u
my_func() {
local arg=${1:-default} # OK — uses default if $1 absent
}
There's a notorious edge case: set -u errors on "${arr[@]}" for an empty array in older bash (< 4.4):
set -u
arr=()
echo "${arr[@]}" # bash 4.3: error; bash 4.4+: works
Use "${arr[@]:-}" for portability if you can't guarantee bash 4.4+.
set -o pipefail — the pipeline fixer
By default, a pipeline exits with the last command's exit code:
false | true; echo "$?" # 0 — `true`'s exit
This means curl http://api/down | jq '.x' silently succeeds when curl fails — you get an empty jq parse and a "0" exit. With pipefail:
set -o pipefail
false | true; echo "$?" # 1 — `false`'s exit propagates
Now set -e aborts when curl fails. pipefail is non-negotiable for any script that uses pipes.
set -x (xtrace) — the debug flag
Print every command (after expansion) before executing:
set -x
name="Sam"
echo "hello, $name"
# +
# + name=Sam
# + echo 'hello, Sam'
# hello, Sam
Indispensable for "why is this script silently doing the wrong thing?" Pair with a custom PS4 for line numbers:
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
Now every traced line shows the source file and line. Logfile-grade debugging.
To trace just a section:
set -x
suspect_section
set +x
set +x turns it off. + flips the flag in set syntax.
set -o noclobber — prevent overwrite
set -o noclobber
echo "data" > existing.log # bash: existing.log: cannot overwrite existing file
echo "data" >| existing.log # `>|` forces overwrite even with noclobber
Useful in interactive shells; rarely worth in scripts (where you usually want explicit truncation).
The noclobber + > "$LOCK" idiom is one way to implement a lock file:
set -o noclobber
( > "$LOCK_FILE" ) || die "already running"
trap 'rm -f "$LOCK_FILE"' EXIT
For real production locking, use flock.
IFS — internal field separator
IFS controls word-splitting. Default: space-tab-newline. Common safer setting:
IFS=$'\n\t' # newline + tab only — preserves spaces in words
Why? Filenames with spaces. With default IFS:
files=$(ls) # files contains "a.txt\nb c.txt\nd.log"
for f in $files; do # word-splits on space, tab, newline
rm "$f" # rm "a.txt", rm "b", rm "c.txt", rm "d.log" ❌
done
With IFS=$'\n\t', the same loop iterates a.txt, b c.txt, d.log correctly.
(Better answer: use for f in * — real glob — instead of parsing ls output. But that's a different lesson.)
Other useful options
| Flag | Long form | What it does |
|---|---|---|
-e |
errexit |
exit on error (with caveats) |
-u |
nounset |
error on unset var |
-x |
xtrace |
trace every command |
-o pipefail |
(no short form) | pipeline = first non-zero exit |
-n |
noexec |
parse but don't run — syntax check |
-f |
noglob |
disable filename expansion |
-C |
noclobber |
> won't overwrite existing files |
-E |
errtrace |
inherit ERR trap into functions and subshells |
-T |
functrace |
inherit DEBUG/RETURN traps into functions |
set -E is worth remembering: without it, your trap '...' ERR doesn't fire inside functions.
set -e — the long debate
There's a long-standing argument in the bash community about whether set -e is good. The detractors point at:
- The exemption list above.
local x=$(may_fail)doesn't trigger errexit (becauselocalalways succeeds).- Errors in subshells:
(false; true)exits 0; the subshell'sset -eisn't the parent's. - "Cargo culted"
set -escripts that have many bugs hidden by|| true.
The fair conclusion: set -e is not a substitute for proper error handling, but it's better than nothing for short scripts. For long scripts, supplement with explicit if cmd; then ... else die "..."; fi at every critical step.
When all else fails — exit code reading
cmd
rc=$?
case $rc in
0) echo "ok" ;;
1) echo "generic failure" ;;
130) echo "interrupted" ;;
*) echo "unknown: $rc" ;;
esac
Useful when an error code is meaningful and you want explicit branches.
Bash vs zsh
zsh has all the same options plus a few:
set -edifferences: zsh's errexit is slightly less aggressive in nested subshells. Test both if portability matters.setoptis zsh's preferred form:setopt err_exit pipe_fail no_unset. The names are spelled out.emulate sh -c '<cmd>'runs a command in POSIX-sh-compatibility mode — useful when sourcing bash-isms in zsh.
For portable scripts, write set -euo pipefail (works in both). For zsh-only, use setopt err_exit pipe_fail no_unset.
The "strict mode" debate, summarised
Pro: Catches the silent failures that cause 90% of bash incidents. Required for any production script.
Con: Lulls you into thinking the script is safer than it is. The exemptions are subtle.
Verdict: Use it. Pair with ShellCheck. Test failure paths.
Common bugs
set -eu doesn't help inside if. if cmd_that_uses_unset_var; then ... won't trigger nounset abort because the entire if-condition is exempt.
local x=$(cmd) — cmd's exit is masked. Split: local x; x=$(cmd) || die. ShellCheck SC2155.
pipefail + grep returning 1 (no match) aborts. Grep returns 1 for "no match", which pipefail propagates and set -e aborts on. Suffix with || true if no-match is OK.
Stripping set -e to "fix" things. Each || true is a comment that says "I expect this to fail sometimes". Without set -e, every failure becomes silent — far worse.
Forgetting IFS change. A script that does IFS=, and forgets to restore it changes the behaviour of every following command. Save and restore: oldIFS=$IFS; IFS=,; ...; IFS=$oldIFS.
Tools in the wild
3 tools- cliShellCheckfree tier
Catches the entire class of bugs strict mode tries to surface, and many it can't. Required in CI.
- clishfmtfree tier
Formatter that pairs naturally with set -euo pipefail scripts. CI: `shfmt -d` to assert.
- clibashatefree tier
Style linter — catches inconsistent quoting, indentation, missing exit codes. Used by OpenStack project-wide.