Scripting Best Practices
Bash defaults are optimized for interactive use: keep going after errors, treat undefined things as empty, ignore failures inside pipelines. Those defaults are wrong for scripts. This page is the set of habits that turn bash from fragile into dependable.
The preamble
Section titled “The preamble”#!/usr/bin/env bashset -euo pipefailIFS=$'\n\t'Three lines, four behaviours. Each one deserves understanding rather than cargo-culting.
set -e (errexit)
Section titled “set -e (errexit)”Exit immediately if a command returns non-zero.
set -ecd /nonexistent # script stops hererm -rf ./build # this would have run against the WRONG directory without -eThe caveats are real, and knowing them is the difference between set -e helping and misleading you.
set -e is suppressed whenever a command’s status is already being tested:
set -eif failing_cmd; then ...; fi # not fatal — the status is the conditionfailing_cmd || handle_error # not fatal — left side of ||failing_cmd && other # not fatal! failing_cmd # not fatalwhile failing_cmd; do ...; done # not fatalThat is by design; it is what makes check || die idiomatic. The traps are these:
set -e
# 1. A function called in a condition loses errexit for its ENTIRE bodycheck() { false; echo "still runs"; }if check; then echo "and we get here"; fi
# 2. Only the LAST command in a pipeline matters (without pipefail)false | true # not fatal
# 3. Arithmetic evaluating to zero is a "failure"i=0(( i++ )) # status 1 -> script exits. Use (( ++i )) or (( i++ )) || true
# 4. inside a function, `local x=$(cmd)` masks cmd's status — `local` itself succeededf() { local x=$(false); echo "still here"; } # not fatalg() { local x; x=$(false); echo "never reached"; } # fatal, as intended
# 5. Command substitution in an assignment is only fatal for a simple assignmentx=$(false) # fatalecho "$(false)" # NOT fatal — the failure is inside an argumentBy default a $( ... ) subshell does not inherit errexit. Bash 4.4+ fixes that with
shopt -s inherit_errexit, which is worth adding.
set -u (nounset)
Section titled “set -u (nounset)”Referencing an unset variable is an error instead of an empty string.
set -uecho "$typo_in_name" # bash: typo_in_name: unbound variable — exitsrm -rf "$dir/" # cannot silently become rm -rf /Deliberately-optional variables need an explicit default:
verbose="${VERBOSE:-}" # "" if unset, and -u is satisfiedecho "${1:-default}" # positional parameters too[[ -n "${DEBUG:-}" ]] && set -xset -o pipefail
Section titled “set -o pipefail”A pipeline’s status is normally its last command’s. pipefail makes it the rightmost non-zero status.
set -o pipefailcurl -fsS "$url" | jq '.items' # now a curl failure fails the pipelineIts caveat is SIGPIPE: producer | head -5 returns 141 once head exits early. Add || true where
early termination is expected.
IFS=$'\n\t'
Section titled “IFS=$'\n\t'”Removes the space from the default word-splitting characters, so an unquoted expansion splits only on
newlines and tabs. It reduces the damage from a missed quote — but it changes behaviour globally and
surprises readers. Optional; correct quoting is the real fix. If you use it, know that
"${arr[*]}" now joins with newlines.
Useful additions
Section titled “Useful additions”shopt -s inherit_errexit # subshells inherit set -e (bash 4.4+)shopt -s nullglob # non-matching globs expand to nothing, not to themselvesshopt -s failglob # non-matching globs are an error (good in scripts)shopt -s globstar # enable ** for recursive globbing (bash 4.0+)Quoting discipline
Section titled “Quoting discipline”Six rules that eliminate most bugs:
- Double-quote every
$varand$(cmd)— no exceptions until you can name the reason. - Use
"$@", never$*or$@, to forward arguments. - Use
"${arr[@]}"to expand arrays. - Use arrays, never strings, to build command lines with optional arguments.
- Prefer
${var:-}underset -ufor anything optional. - Terminate option parsing with
--before user data:rm -- "$file",grep -- "$pat" file.
# building a command safelycmd=(rsync -a)[[ -n "${DRY_RUN:-}" ]] && cmd+=(--dry-run)[[ -n "${EXCLUDE:-}" ]] && cmd+=(--exclude="$EXCLUDE")"${cmd[@]}" -- "$src" "$dst"trap: cleanup that always runs
Section titled “trap: cleanup that always runs”trap 'commands' SIGNAL... registers a handler. The pseudo-signal EXIT fires whenever the shell
exits — normally, via exit, or because set -e fired — which makes it the right place for cleanup.
#!/usr/bin/env bashset -euo pipefail
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/myscript.XXXXXX")trap 'rm -rf "$tmpdir"' EXIT
# ... work in "$tmpdir" ...That is the single most valuable pattern on this page: the temp directory is removed on success, on
failure, and on Ctrl-C, without repeating cleanup at every exit.
Notes on the mechanics:
- Single-quote the handler so
$tmpdiris expanded when the trap fires, not when it is registered. Double quotes bake in the value at registration time, which is wrong if the variable is set later. - Register the trap immediately after creating the resource.
- The EXIT trap also runs for SIGINT/SIGTERM if those signals are not trapped separately — bash runs the EXIT trap when the shell terminates from a trapped-or-default fatal signal that it handles. Trapping them explicitly gives you control over the exit status.
cleanup() { local status=$? # capture BEFORE running anything else rm -rf "$tmpdir" [[ $status -ne 0 ]] && echo "failed with status $status" >&2 return $status}trap cleanup EXITtrap 'echo "interrupted" >&2; exit 130' INT TERMAn ERR trap reports the failing line; add set -E so it is inherited by functions and subshells:
set -eEtrap 'echo "error on line $LINENO: $BASH_COMMAND" >&2' ERROther useful pieces: trap - EXIT removes a handler, trap -p lists the current ones, and traps are
not inherited by child processes (they are reset to defaults in the child).
Argument parsing
Section titled “Argument parsing”getopts (short options)
Section titled “getopts (short options)”getopts is a builtin, POSIX, and handles bundling (-abc) and attached values (-ofile). It does
not support long options.
#!/usr/bin/env bashset -euo pipefail
usage() { cat <<EOFUsage: ${0##*/} [-v] [-n COUNT] [-o FILE] FILE... -v verbose -n COUNT number of iterations (default 1) -o FILE output file -h show this helpEOF}
verbose=0count=1outfile=""
while getopts ":vn:o:h" opt; do case "$opt" in v) verbose=1 ;; n) count="$OPTARG" ;; o) outfile="$OPTARG" ;; h) usage; exit 0 ;; \?) echo "unknown option: -$OPTARG" >&2; usage >&2; exit 2 ;; :) echo "option -$OPTARG requires an argument" >&2; exit 2 ;; esacdoneshift $(( OPTIND - 1 )) # drop the parsed options; "$@" is now the operands
(( $# )) || { echo "no input files" >&2; usage >&2; exit 2; }How the option string works: a letter alone is a flag, a letter followed by : takes an argument. A
leading colon switches on silent error reporting, which is what enables the \? and : cases
above — without it, getopts prints its own message and sets opt to ?.
OPTIND is the index of the next argument; shift $(( OPTIND - 1 )) is mandatory. If you parse
options more than once in the same shell, reset OPTIND=1 first.
while/case loop (long options)
Section titled “while/case loop (long options)”For --long-form options, hand-roll the loop. More code, more control.
verbose=0count=1outfile=""files=()
while [[ $# -gt 0 ]]; do case "$1" in -v|--verbose) verbose=1; shift ;; -n|--count) count="${2:?--count needs a value}"; shift 2 ;; --count=*) count="${1#*=}"; shift ;; -o|--output) outfile="${2:?--output needs a value}"; shift 2 ;; --output=*) outfile="${1#*=}"; shift ;; -h|--help) usage; exit 0 ;; --) shift; files+=("$@"); break ;; -*) echo "unknown option: $1" >&2; exit 2 ;; *) files+=("$1"); shift ;; esacdoneHandling -- explicitly is what lets a user pass a file literally named -v. The --opt=value cases
are what most users expect from a modern CLI.
Input validation
Section titled “Input validation”Validate at the boundary, then trust the values internally.
die() { echo "error: $*" >&2; exit 1; }
[[ -n "${count:-}" ]] || die "count is required"[[ "$count" =~ ^[0-9]+$ ]] || die "count must be a non-negative integer: $count"(( count > 0 && count <= 100 )) || die "count must be 1-100"
[[ -r "$infile" ]] || die "cannot read $infile"[[ -d "$outdir" ]] || die "not a directory: $outdir"command -v jq >/dev/null 2>&1 || die "jq is required"
case "$mode" in fast|slow|auto) ;; *) die "mode must be fast, slow, or auto" ;;esacFor paths supplied by users, reject traversal explicitly rather than trying to sanitize:
[[ "$name" == */* ]] && die "name must not contain a path separator"[[ "$name" == .* ]] && die "name must not start with a dot"ShellCheck
Section titled “ShellCheck”ShellCheck is a static analyzer for shell scripts and the highest-value tool in this section. It finds
quoting bugs, useless cats, misused [ ], and unreachable code.
sudo apt install shellcheck # or: brew install shellcheckshellcheck myscript.shshellcheck -s bash -S warning myscript.sh # force dialect, minimum severityshellcheck ./*.shFrequent findings worth recognizing:
| Code | Meaning |
|---|---|
| SC2086 | Double quote to prevent globbing and word splitting — the big one |
| SC2046 | Quote this command substitution to prevent word splitting |
| SC2006 | Use $(...) instead of backticks |
| SC2164 | Use cd … || exit in case cd fails |
| SC2155 | Declare and assign separately to avoid masking the return value |
| SC2181 | Check the exit code directly instead of via $? |
Suppress a check only with a reason, on the line above:
# shellcheck disable=SC2016 # single quotes are intentional; awk uses $1awk '{print $1}' fileAdd a # shellcheck shell=bash directive to sourced files that have no shebang, and wire
shellcheck into CI — it is fast enough to run on every commit.
Debugging
Section titled “Debugging”bash -n script.sh # syntax check only, runs nothingbash -x script.sh # print each command after expansion, as it runsbash -xv script.sh # -v also prints the raw source linesTurn tracing on for a region rather than the whole script:
set -xsuspicious_function "$arg"set +xMake the trace readable by including location information in PS4:
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: 'set -xOutput now looks like + deploy.sh:42:main: rm -rf /tmp/build, which tells you exactly what expanded
to what. Since -x writes to stderr and can pollute your output, redirect it separately:
exec 5> /tmp/trace.logBASH_XTRACEFD=5set -xA debug flag driven by the environment is a good habit:
[[ -n "${DEBUG:-}" ]] && set -x# run as: DEBUG=1 ./script.shOther tools: caller prints the current call stack frame, declare -p var shows a variable’s exact
value and attributes (including trailing whitespace), and set -v echoes input lines as they are read.
Safe filename handling
Section titled “Safe filename handling”Filenames may contain spaces, newlines, quotes, and leading dashes. Only two things are forbidden: the
NUL byte and /. Every safe technique follows from that.
# iterate: use a glob, not `ls`for f in ./*.txt; do [[ -e "$f" ]] || continue # or shopt -s nullglob process -- "$f"done
# recurse: NUL-separatedwhile IFS= read -r -d '' f; do process -- "$f"done < <(find . -type f -print0)
# feed another commandfind . -type f -print0 | xargs -0 -n1 process --Three rules:
- Never parse
ls. Its output is ambiguous for names containing newlines and it mangles non-printable characters. - Prefix globs with
./. Without it, a file named-rfbecomes an option to whatever runs next. - Use
--before user-supplied arguments.rm -- "$f",grep -e "$pat" -- "$f".
Classic footguns
Section titled “Classic footguns”| Footgun | What happens | Fix |
|---|---|---|
rm $file |
Word splitting on spaces; empty var deletes nothing or the wrong thing | rm -- "$file" |
if [ $x = y ] |
Empty $x makes it a syntax error |
if [[ "$x" == y ]] |
cd $dir; rm -rf * |
If cd fails, you delete the wrong directory |
cd "$dir" || exit 1 |
for f in $(ls) |
Splits on whitespace, mangles names | for f in ./* |
count=0; cat f | while read … |
Loop runs in a subshell; count is unchanged |
while read … done < f |
x=$(cmd) then if [ $? …] |
$? may already be overwritten |
if cmd; then |
echo "$var" with -n or -e content |
echo interprets it inconsistently across shells |
printf '%s\n' "$var" |
[[ $a > $b ]] on numbers |
String comparison: “9” > “10” | (( a > b )) |
var = value |
Runs the command var |
var=value |
Unquoted glob in [[ x == $pat ]] |
Right side is a pattern, not a literal | Quote it: "$pat" |
A complete template
Section titled “A complete template”#!/usr/bin/env bash## Short description of what this script does.
set -euo pipefailshopt -s inherit_errexit 2>/dev/null || true # bash 4.4+; ignore on older bash
readonly SCRIPT_NAME="${0##*/}"readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
log() { printf '[%s] %s\n' "$(date +%T)" "$*" >&2; }die() { printf 'error: %s\n' "$*" >&2; exit 1; }
usage() { cat <<EOFUsage: $SCRIPT_NAME [-v] -o OUTPUT INPUT...EOF}
main() { local verbose=0 outfile=""
while getopts ":vo:h" opt; do case "$opt" in v) verbose=1 ;; o) outfile="$OPTARG" ;; h) usage; exit 0 ;; \?) die "unknown option: -$OPTARG" ;; :) die "option -$OPTARG requires an argument" ;; esac done shift $(( OPTIND - 1 ))
[[ -n "$outfile" ]] || die "-o is required" (( $# )) || die "no input files"
local tmpdir tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/${SCRIPT_NAME}.XXXXXX") trap 'rm -rf "$tmpdir"' EXIT
local f for f in "$@"; do [[ -r "$f" ]] || die "cannot read: $f" (( verbose )) && log "processing $f" cat -- "$f" >> "$tmpdir/combined" done
mv -- "$tmpdir/combined" "$outfile" log "wrote $outfile"}
main "$@"Wrapping the body in main and calling main "$@" at the end has a practical benefit beyond tidiness:
bash reads scripts incrementally, so a script that is edited while running can behave bizarrely.
Defining everything first and executing on the last line makes that impossible.
Key points
Section titled “Key points”set -euo pipefailcatches the errors you did not anticipate; explicit checks handle the ones you did. Learn its blind spots — conditions, functions,local x=$(...),(( i++ )).trap 'rm -rf "$tmpdir"' EXITright aftermktemp -dis the highest-value line in most scripts.- Quote everything; use arrays for command lines; never
evaluser input. getoptsfor short options, a while/case loop for long ones,shift $(( OPTIND - 1 ))either way.- Run ShellCheck; it finds real bugs faster than you do.
- Debug with
bash -n,set -x, and aPS4that shows file and line. - Filenames are hostile:
-print0/read -d '',./prefixes,--terminators, never parsels.