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.
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
| Code | Conventional meaning |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Misuse of shell builtin / bad arguments |
| 126 | Command found but not executable |
| 127 | Command not found |
| 128+n | Killed 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.
# 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 withmain "$@". - Quote every expansion unless you can justify not quoting it.
- Prefer
[[ ]]and$(( ))over[ ]andexpr. - Use
printfrather thanechofor 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. mktempfor temporary files with atrapcleanup.shellcheckclean.- Tested on the target shell (
bash --posixordashif portability matters). - Meaningful exit codes and error messages on stderr.