Signal9Signal9 — home
online
all lessons

Robust Bash: Strict Mode, Traps & Debugging

Advanced35 min6 steps
What you'll learn
  • Explain why the default shell hides failures, and turn that off with set -euo pipefail
  • Guard a script's inputs and fail fast with clear stderr messages and distinct exit codes
  • Clean up reliably with a trap on EXIT, no matter how the script ends
  • Debug a misbehaving script with set -x, bash -x, and a useful PS4
  • Recognise the pipefail gap and the unquoted-variable trap that survive even 'set -e'
  1. The default shell is too forgiving

    See the three silent failures strict mode turns into loud ones

    By default, Bash keeps going after almost anything. A command fails? It runs the next one. You typo a variable name? It expands to the empty string. A command mid-pipeline dies? The pipeline still reports success. For an interactive shell that forgiveness is convenient. For a script that provisions a server or deletes files, it's how rm -rf "$dir/" becomes rm -rf / when $dir was a typo.

    Three short options flip those silent failures into immediate, visible stops. Together they're called strict mode, and they belong at the top of nearly every script you write.

    Check yourself

    With default settings, what does Bash do with a reference to a variable you never set?

  2. Strict mode: set -euo pipefail

    Turn on the three guards and know what each one does

    One line, three guards:

    • `-e` — exit the moment any command fails (non-zero) instead of continuing.
    • `-u` — treat an unset variable as an error, not an empty string.
    • `-o pipefail` — a pipeline fails if any stage fails, not just the last one.

    Write it as set -euo pipefail right after the shebang. Together they mean: the script stops at the first real problem, with the failing command's exit status.

    cat > strict.sh <<'EOF'
    #!/bin/bash
    set -euo pipefail
    
    dir=${1:?usage: strict.sh <dir>}
    count=$(ls "$dir" | wc -l)
    echo "$dir has $count entries"
    EOF
    cat strict.sh
    #!/bin/bash
    set -euo pipefail
    
    dir=${1:?usage: strict.sh <dir>}
    count=$(ls "$dir" | wc -l)
    echo "$dir has $count entries"
    -e
    exit on the first command that returns non-zero
    -u
    error on use of an unset variable
    -o pipefail
    a pipeline's exit status is the first non-zero stage, not just the last
    ${1:?msg}
    require $1; abort with msg if it's unset/empty
    The skeleton. set -euo pipefail is line 2. ${1:?msg} is strict mode's partner for arguments: if $1 is missing it prints 'usage: ...' and exits non-zero — you can't accidentally run with no directory. Because of pipefail, if 'ls' fails the whole 'ls | wc -l' fails and -e stops the script.

    Check yourself

    Which part of 'set -euo pipefail' makes 'grep foo file | sort' fail when grep finds nothing?

  3. Clean up with trap ... EXIT

    Guarantee cleanup runs no matter how the script ends

    Scripts create temporary things — a scratch file, a lock, a mounted directory. If the script exits early (an error under set -e, a Ctrl-C, a kill), naive cleanup at the bottom never runs and the mess is left behind. `trap 'cleanup' EXIT` registers a handler that fires on every exit path — success, failure, or signal. Set it up before you create the thing, and cleanup becomes automatic.

    tmp=$(mktemp)
    trap 'rm -f "$tmp"' EXIT
    echo "working in $tmp"
    # ... even if the next line fails under set -e, $tmp is removed
    false
    working in /tmp/tmp.a1B2c3
    trap 'cmd' EXIT
    run cmd on every exit — normal, error, or signal
    mktemp
    create a uniquely-named temp file (no name collisions)
    mktemp makes a unique scratch file; the trap on EXIT deletes it. Then 'false' fails — under set -e the script exits immediately — but the trap still fires and removes $tmp. Order matters: register the trap right after creating the resource, so any failure in between is still covered.

    Check yourself

    Why register the EXIT trap immediately after creating the temp file, not at the end of the script?

  4. Validate input, report to stderr, exit with meaning

    Make the script's interface predictable for humans and callers

    A robust script checks its inputs before acting, sends error and usage text to stderr (so it never pollutes captured output), and exits with a distinct non-zero code per failure mode so callers and CI can branch on why it failed. Guard clauses at the top keep the happy path clean.

    cat > deploy.sh <<'EOF'
    #!/bin/bash
    set -euo pipefail
    name=${1:-}
    [ -n "$name" ] || { echo "usage: deploy.sh <name>" >&2; exit 2; }
    [ -f "/etc/app/$name.conf" ] || { echo "error: unknown config $name" >&2; exit 3; }
    echo "deploying $name"
    EOF
    cat deploy.sh
    #!/bin/bash
    set -euo pipefail
    name=${1:-}
    [ -n "$name" ] || { echo "usage: deploy.sh <name>" >&2; exit 2; }
    [ -f "/etc/app/$name.conf" ] || { echo "error: unknown config $name" >&2; exit 3; }
    echo "deploying $name"
    >&2
    send this line to stderr — the channel for errors, not results
    exit 2 / exit 3
    distinct codes let callers branch on the failure reason
    Two guard clauses, two exit codes. No argument → usage on stderr, exit 2. Unknown config → error on stderr, exit 3. The happy path ('deploying') is only reached when both guards pass. A caller can now tell a usage mistake (2) from a missing config (3) — and neither error lands in stdout that a pipeline might capture.

    Check yourself

    Why give 'no argument' and 'unknown config' different exit codes instead of both exiting 1?

  5. Debugging: watch the script run

    Trace execution with set -x and a readable PS4

    When a script does the wrong thing, stop guessing and watch it run. `set -x` (xtrace) prints each command after expansion, right before it executes — so you see the real values, not the source. Turn it on for a section (set -x … set +x), or run the whole script with bash -x script.sh. A richer `PS4` prompt adds the line number so you know exactly where each traced line came from.

    PS4='+ ${BASH_SOURCE}:${LINENO}: '
    set -x
    name=deploy
    dir=/srv/$name
    set +x
    + script.sh:3: name=deploy
    + script.sh:4: dir=/srv/deploy
    + script.sh:5: set +x
    set -x / set +x
    turn execution tracing on / off
    PS4
    the trace prefix — add ${LINENO} to show line numbers
    bash -x script.sh
    trace an entire script without editing it
    With xtrace on, each line is echoed AFTER expansion — you can see dir became /srv/deploy, the actual value. The custom PS4 prefixes the source file and line number, so in a long script you know precisely which line produced which trace. set +x turns it back off.

    Check yourself

    You suspect a variable holds the wrong value halfway through a script. What's the fastest way to confirm without adding echo everywhere?

  6. Now do it — harden a script in your terminal

    Add strict mode, a cleanup trap, and a guard, then watch it run

    Take a throwaway script from limp to robust in four edits: strict mode on top, a guard clause for input, a trap for cleanup, and a quick xtrace to confirm. This is exactly the shape the defensive-scripting and debugging labs will grade.

    cat > h.sh <<'EOF'
    #!/bin/bash
    set -euo pipefail
    name=${1:?usage: h.sh <name>}
    tmp=$(mktemp)
    trap 'rm -f "$tmp"' EXIT
    echo "$name" > "$tmp"
    echo "wrote $(wc -l < "$tmp") line for $name"
    EOF
    bash h.sh alice
    ${1:?usage...}
    require the argument; abort with the message if missing
    A complete robust mini-script: strict mode, a required argument via ${1:?...}, a temp file with an EXIT trap that guarantees cleanup, and honest output. Run it with an argument (works) and then with NONE — watch ${1:?} stop it with the usage message and a non-zero exit.
    bash -x h.sh alice 2>&1 | head -6
    bash -x
    run with xtrace on for the whole script
    Trace the same script with bash -x — no edits needed — and read how each line expands. This is your first move whenever a script misbehaves: run it under -x and watch the real values flow through.
Recap
  • set -euo pipefail is the robust-script floor: -e stops on the first failure, -u errors on unset variables, pipefail propagates a failing pipeline stage.
  • Arm 'trap cleanup EXIT' right after creating a temp file/lock so cleanup runs on every exit path — success, error, or Ctrl-C.
  • Validate inputs with guard clauses, send usage/errors to stderr (>&2), and exit with distinct non-zero codes so callers can branch on the reason.
  • Debug by watching execution: set -x / set +x around a block, or bash -x whole-script; add ${LINENO} to PS4 for line numbers.
  • Strict mode is a floor, not a ceiling — it ignores commands in if/&&, it can't fix quoting, and an unmatched glob still arrives literally (shopt -s nullglob fixes that), so keep every variable quoted and never leave xtrace on around secrets.
Concept → syntax
ConceptSyntax
Strict modeset -euo pipefail
Require an argumentname=${1:?usage: ...}
Guaranteed cleanuptrap 'rm -f "$tmp"' EXIT
Guard clause[ -n "$x" ] || { echo msg >&2; exit 2; }
Error to stderrecho "..." >&2
Trace a block / a scriptset -x … set +x · bash -x s.sh