Robust Script Engineering

Strict Mode

By default Bash is forgiving: an unset variable expands to nothing, a failed command does not stop the script, and a broken pipeline reports success. Strict mode turns those defaults off.

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

errexit (-e)

Exit as soon as a command fails. This stops a script from continuing after a failed cd, mkdir or download.

set -e
cd /does/not/exist        # script stops here
echo "never reached"

Two important exceptions: a command in an if/while condition or preceded by ! is allowed to fail, and a command on the left of &&/|| does not trigger the exit.

nounset (-u)

Treat an unset variable as an error instead of an empty string. This catches typos such as $PAHT.

set -u
echo "$MISSING"                 # ERROR: MISSING: unbound variable
echo "${MISSING:-fallback}"     # OK: explicit default

pipefail

A pipeline returns the status of its last command by default, hiding failures upstream. pipefail returns the rightmost non-zero status.

set -o pipefail
grep "critical" app.log | head -1     # fails if grep fails, even though head succeeded

Reset IFS

Setting IFS to newline and tab removes the space as a word-splitting character, reducing accidental splitting. Set it only if your script does not depend on the default.

Strict mode is a tool, not a religion. set -e has sharp edges around arithmetic (((i++)) returns non-zero when the old value was 0) and command substitution. Test carefully, and prefer explicit || exit 1 where the intent must be obvious.

Exit Codes & Errors

Meaningful Codes

CodeConventional meaning
0Success
1General error
2Misuse of shell builtin / bad arguments
126Command found but not executable
127Command not found
128+nKilled by signal n (130 = 128+2, Ctrl-C)
readonly E_USAGE=64
readonly E_NOCONFIG=78

usage() { echo "usage: $0 -c CONFIG" >&2; exit "$E_USAGE"; }
[[ $# -eq 0 ]] && usage

Error Messages

die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }

[[ -f "$cfg" ]] || die "config not found: $cfg"

Always send diagnostics to stderr and include the program name and the offending value.

traps for Errors

on_err() {
  local status=$1 line=$2
  printf 'FAILED at line %d (status %d): %s\n' \
         "$line" "$status" "${BASH_COMMAND}" >&2
}
trap 'on_err $? $LINENO' ERR

trap 'rm -f "$tmp"' EXIT        # always clean up

ShellCheck

ShellCheck is a static analyser that finds the mistakes humans miss: unquoted variables, useless cat, misused tests, unsafe rm. Run it on every script.

shellcheck script.sh
shellcheck -S warning script.sh        # only warnings and above
shellcheck -f gcc script.sh            # editor-friendly output

Wire it into CI and your editor. Most findings explain not just what is wrong but why, with a link to a wiki page.

Every warning has an explanation and a suggested fix. Do not disable a check with # shellcheck disable=SC#### without a comment saying why.


Debugging

Trace Execution

bash -x script.sh          # trace from the start
set -x ; risky_part ; set +x   # trace only a region
PS4='+ ${BASH_SOURCE}:${LINENO}: '   # richer prompt for the trace

Reading the Trace

The trace shows each command after expansion, which reveals quoting bugs instantly: if you see two words where you expected one, you forgot to quote.

A Debug Helper

debug() {
  [[ -n "${DEBUG:-}" ]] || return 0
  printf '[debug] %s:%s: %s\n' "${BASH_SOURCE[1]}" "${BASH_LINENO[0]}" "$*" >&2
}
debug "value is $value"     # only prints when DEBUG=1 is set

Testing

bats — Bash Automated Testing System

# test_add.bats
setup()    { source ./lib/math.sh; }

@test "adds two numbers" {
  run add 2 3
  [ "$status" -eq 0 ]
  [ "$output" -eq 5 ]
}

@test "rejects non-numbers" {
  run add a b
  [ "$status" -ne 0 ]
}

bats runs each case in a fresh process, so tests do not leak state into one another. Another option is shunit2.

Without a Framework

assert_eq() {
  [[ "$1" == "$2" ]] || { echo "expected '$2' got '$1'" >&2; exit 1; }
}
assert_eq "$(slugify 'Hello World')" "hello-world"

Style & Structure

  • Start with a shebang, a one-line purpose comment and the strict-mode line.
  • Keep main() last and call it with main "$@".
  • Quote every expansion unless you can justify not quoting it.
  • Prefer [[ ]] and $(( )) over [ ] and expr.
  • Use printf rather than echo for anything non-trivial.
  • Follow an existing style guide — the Google Shell Style Guide is a solid default.

Pre-Flight Checklist

  • Shebang present; script is executable; LF line endings.
  • set -euo pipefail (or a documented reason not to).
  • All variables quoted; "$@" used for arguments.
  • mktemp for temporary files with a trap cleanup.
  • shellcheck clean.
  • Tested on the target shell (bash --posix or dash if portability matters).
  • Meaningful exit codes and error messages on stderr.