Linux Basic — set -e and set -u

4 minute read

set -e and set -u

Two of the three “strict mode” switches. set -e (a.k.a. errexit) makes the script exit on the first error. set -u (a.k.a. nounset) makes unset variables a fatal error. Together they catch most “silent failure” bugs — but each has surprises that bite the unprepared.


set -e — Exit on Error

#!/usr/bin/env bash
set -e

cp src.txt dst.txt        # if this fails, the script exits here
chmod 600 dst.txt         # this runs only if the cp succeeded
echo "done"

Without set -e, both commands run regardless of whether cp worked, and you’d silently have a missing destination file with a chmod 600 error you ignored.

Equivalent: set -o errexit and the shebang flag bash -e script.sh.


Where set -e Does NOT Trigger

This is the part everyone gets wrong. set -e ignores failure in these contexts:

1. Commands in conditions

set -e

if grep -q ERROR file.log; then …          # grep returning 1 does NOT abort
fi

while read -r line; do …                   # read returning 1 (EOF) does NOT abort
done < file.txt

cmd || true                                 # the `|| true` swallows the error

This is by design — you need to be able to test things without the script bailing.

2. Commands left of && / || / |

set -e

false && true               # does NOT abort — false is being tested
false || echo "fine"        # does NOT abort — false's failure is handled
false | true                # does NOT abort by default — only the last command counts

3. Negated commands

set -e
! some_cmd                  # never aborts, regardless of some_cmd's exit code

4. Inside functions called in conditions

This is the famous bug:

set -e

my_func() {
    false
    echo "still here?"      # YES — even though `false` failed
}

if my_func; then            # function called in 'if' → set -e inside is suspended
    echo "ok"
fi

When a function is called in a condition (or with || …, && …), set -e is disabled inside it. There’s no workaround at the function level; you must check the failure manually:

my_func() {
    false || return $?
    echo "won't reach here"
}

This is why many people consider set -e a half-solution. It catches the common cases but you still need explicit checks for the corners.


Inverting set -e — Per-Command Opt-Out

When you specifically want a command to be allowed to fail under set -e:

set -e

# Method 1 — || true
optional_thing || true

# Method 2 — || :
optional_thing || :

# Method 3 — disable just for this line
set +e
risky_command
rc=$?
set -e

# Method 4 — use 'if' (cleaner than +e/-e)
if ! risky_command; then
    rc=$?
    echo "expected failure, code $rc" >&2
fi

: (the no-op) and true are equivalent. The if form is the most readable when you also want to capture the exit code.


set -u — Error on Unset Variables

#!/usr/bin/env bash
set -u

name="Alice"
echo "Hello $nmae"           # typo → script exits
# bash: nmae: unbound variable

This catches the classic typo bug that has bitten every shell programmer. With set -u, typos fail loudly instead of expanding to empty string.

Equivalent: set -o nounset.

Where set -u bites you

set -u

# Iterating an empty array (Bash < 4.4)
arr=()
for x in "${arr[@]}"; do echo "$x"; done
# bash: arr[@]: unbound variable        ← in older Bash, "[@]" on empty array was an error

(Bash 4.4+ treats "${arr[@]}" on an empty array as zero words. Verify with bash --version if you support older systems.)

set -u
echo "$1"                    # if no argument passed, script exits
echo "${1:-default}"         # safe — explicit default

Use the :- default for optional vars:

log_level=${LOG_LEVEL:-info}
out_dir=${1:-./build}

For checking whether a variable is set without triggering set -u:

[[ -v MAYBE ]] && echo "set"      # Bash 4.2+
[[ -z "${MAYBE:-}" ]] && echo "empty or unset"

Strict-Mode Header — The Recipe

Most production scripts start with:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

Breakdown:

  • -e — errexit
  • -u — nounset
  • -o pipefail — fail pipelines on any non-zero exit (next page)
  • IFS=$'\n\t' — word-split only on newline/tab; defends against unquoted-variable surprises

This is sometimes called “unofficial Bash strict mode” and it’s a great starting point.

When not to use strict mode:

  • One-shot interactive snippets (annoying)
  • Scripts where exit codes are part of the contract (e.g. some checking tools intentionally exit 1 on “found mismatch”)
  • The first 5 minutes of a script before you’ve defined a die helper

A Real-World Pattern — Strict But Friendly

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

die() { echo "ERROR: $*" >&2; exit 1; }

# Required env vars
: "${API_TOKEN:?API_TOKEN required}"
: "${REGION:?REGION required}"

# Optional with defaults
log_level=${LOG_LEVEL:-info}
dry_run=${DRY_RUN:-false}

main() {
    local input=${1:-} 
    [[ -n "$input" ]] || die "Usage: $0 <input-file>"
    [[ -r "$input" ]] || die "Cannot read $input"

    process "$input" || die "processing failed"
}

main "$@"

Strict mode catches typos and silent failures; explicit checks with die provide friendly error messages.


Common Mistakes

❌ Wrong ✅ Right Issue
Relying on set -e to catch errors inside a function called in if check \|\| return inside the function set -e suspended in conditions
Using bare $var with set -u for optional vars ${var:-default} or ${var:?required} unbound triggers exit
Wrapping every command in \|\| true “just in case” use set +e block or if makes errors invisible — defeats the point
set -e; cmd1; cmd2; cmd3 for a “transaction” use a function that returns on failure, or trap ERR only catches one command at a time
Forgetting IFS=$'\n\t' add it to the header many array/glob bugs vanish

Next → set -o pipefail

Updated: