Writing Robust Bash Scripts¶
Most "it works on my machine" shell scripts fail the moment they hit an unexpected input, an empty variable, or a command that fails silently. This article covers the practices that make the difference between a script that fails loudly and safely, and one that corrupts data at 3am.
How it works¶
Bash executes a script line by line, by default ignoring the exit status of every command except the last one in a pipeline, and treating unset variables as empty strings rather than errors. Both defaults are wrong for almost any script you'll write in production.
set -euo pipefail¶
set -e— exit immediately if any command returns a non-zero exit status (with some exceptions: commands in anifcondition, or followed by&&/||, are exempt, since their failure is explicitly being checked).set -u— treat referencing an unset variable as an error, instead of silently substituting an empty string. This catches typos like$FIELinstead of$FILEimmediately.set -o pipefail— in a pipeline (cmd1 | cmd2), the overall exit status becomes the last (rightmost) non-zero status among all stages, instead of only the last command's status. Without this,grep pattern missingfile.txt | sortcan fail silently —grep's error is swallowed becausesort(which never sees any error) is what determines the pipeline's reported exit status.
This combination is sometimes called "bash strict mode." It is not a silver bullet — a command you expect might fail (like grep finding no matches) needs explicit handling, or the whole script exits:
Functions and local variables¶
Output:
local matters: without it, name would be a global variable, visible to (and overwritable by) every other function in the script — a common source of bugs in scripts that grew past a few dozen lines.
Argument parsing¶
A while/case loop is the standard, dependency-free way to parse flags and options:
#!/usr/bin/env bash
set -euo pipefail
verbose=0
while [[ $# -gt 0 ]]; do
case "$1" in
-v|--verbose) verbose=1; shift ;;
-f|--file) file="$2"; shift 2 ;;
*) echo "unknown arg: $1" >&2; exit 1 ;;
esac
done
echo "verbose=$verbose file=${file:-none}"
Run as ./script.sh -v -f /etc/hosts:
Note ${file:-none} — this is a default-value expansion: if file was never set (the -f flag wasn't passed), it evaluates to the literal string none instead of erroring under set -u.
Ubuntu/Debian vs. RHEL-family differences¶
Bash itself behaves identically across distributions — the differences are in what /bin/sh points to. On Debian/Ubuntu, /bin/sh is dash, a POSIX-minimal shell that does not support bash-isms like [[ ]], arrays, or process substitution. On RHEL-family systems, /bin/sh is usually bash itself (running in a restricted POSIX-compatible mode). Always shebang your scripts #!/usr/bin/env bash, not #!/bin/sh, unless you've deliberately written POSIX-only sh syntax and tested it with dash specifically.
Practical exercise¶
- Write a script
backup-check.shthat takes a directory path as its only argument, usesset -euo pipefail, and exits with an error message (tostderr) and a non-zero exit code if the directory doesn't exist. - Add a
localfunction that counts the number of files in that directory usingfindpiped towc -l, and prints the count. - Deliberately break
pipefailprotection by piping a command that doesn't exist intowc -l, observe the (wrong) exit code withoutpipefail, then addset -o pipefailback and observe the corrected exit code.
How to verify you did it correctly¶
./backup-check.sh /nonexistent; echo $?should print aNo such directorystyle message to stderr and a final exit code of1(or whatever non-zero code you chose), not0.nonexistentcmd | wc -l; echo $?withoutpipefailprints0(wrong —wc -lsucceeded even thoughnonexistentcmdfailed); withset -o pipefail, it prints127(or1, the actual failing stage's status).
Common mistakes¶
- Unquoted variable expansions:
rm -rf $DIR/*instead ofrm -rf "${DIR}"/*. IfDIRis unset or contains spaces/globs, this can expand into something far more destructive than intended. Always quote variable expansions unless you specifically need word-splitting.rm -rf "${DIR:?}"/*aborts the script whenDIRis empty or unset, even withoutset -u. - Checking
$?too late.$?reflects the exit status of the immediately preceding command — inserting anechofor debugging between the command and the check silently breaks it. - Using
[ ]instead of[[ ]]for anything beyond the simplest POSIX comparisons.[[ ]]avoids word-splitting/globbing surprises and supports&&/||/regex matching directly.
⚠ Dangerous commands¶
- Any script using
rm -rf "$VAR"where$VARcould be empty or unset. Combined withset -u, an unset variable will stop the script (good) — but withoutset -u,rm -rf "$VAR"/with an empty$VARbecomesrm -rf /, deleting the root filesystem (blocked by modernrmfor the literal/path, but not for$VAR/subdirexpanding unexpectedly). - Scripts that
evaluser-supplied input are a direct path to arbitrary code execution — avoidevalentirely unless you fully control every byte of the string being evaluated.
Related articles¶
Review questions¶
- What does
set -o pipefailchange about how a pipeline's exit status is determined? - Why does
set -ucatch bugs thatset -ealone would miss? - Why should
#!/usr/bin/env bashgenerally be preferred over#!/bin/shfor scripts using bash-specific syntax? - What's the practical difference between
local name="$1"andname="$1"inside a function?