bash · level 8

set Options

set -euo pipefail, IFS, set -x — the four-line opener that turns a fragile bash script into one you can trust.

175 XP

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:

  1. The condition of if, while, until (otherwise you couldn't test for failure).
  2. The left side of && or || (otherwise short-circuit wouldn't work).
  3. The body of a function called via || (e.g. func || cleanup).
  4. Inside a pipeline (only the LAST command's exit matters — that's why you also need pipefail).
  5. 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 (because local always succeeds).
  • Errors in subshells: (false; true) exits 0; the subshell's set -e isn't the parent's.
  • "Cargo culted" set -e scripts 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 -e differences: zsh's errexit is slightly less aggressive in nested subshells. Test both if portability matters.
  • setopt is 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
  • ShellCheckfree tier

    Catches the entire class of bugs strict mode tries to surface, and many it can't. Required in CI.

    cli
  • shfmtfree tier

    Formatter that pairs naturally with set -euo pipefail scripts. CI: `shfmt -d` to assert.

    cli
  • bashatefree tier

    Style linter — catches inconsistent quoting, indentation, missing exit codes. Used by OpenStack project-wide.

    cli