Skip to content

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.

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

Three lines, four behaviours. Each one deserves understanding rather than cargo-culting.

Exit immediately if a command returns non-zero.

Terminal window
set -e
cd /nonexistent # script stops here
rm -rf ./build # this would have run against the WRONG directory without -e

The 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:

Terminal window
set -e
if failing_cmd; then ...; fi # not fatal — the status is the condition
failing_cmd || handle_error # not fatal — left side of ||
failing_cmd && other # not fatal
! failing_cmd # not fatal
while failing_cmd; do ...; done # not fatal

That is by design; it is what makes check || die idiomatic. The traps are these:

Terminal window
set -e
# 1. A function called in a condition loses errexit for its ENTIRE body
check() { 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 succeeded
f() { local x=$(false); echo "still here"; } # not fatal
g() { local x; x=$(false); echo "never reached"; } # fatal, as intended
# 5. Command substitution in an assignment is only fatal for a simple assignment
x=$(false) # fatal
echo "$(false)" # NOT fatal — the failure is inside an argument

By default a $( ... ) subshell does not inherit errexit. Bash 4.4+ fixes that with shopt -s inherit_errexit, which is worth adding.

Referencing an unset variable is an error instead of an empty string.

Terminal window
set -u
echo "$typo_in_name" # bash: typo_in_name: unbound variable — exits
rm -rf "$dir/" # cannot silently become rm -rf /

Deliberately-optional variables need an explicit default:

Terminal window
verbose="${VERBOSE:-}" # "" if unset, and -u is satisfied
echo "${1:-default}" # positional parameters too
[[ -n "${DEBUG:-}" ]] && set -x

A pipeline’s status is normally its last command’s. pipefail makes it the rightmost non-zero status.

Terminal window
set -o pipefail
curl -fsS "$url" | jq '.items' # now a curl failure fails the pipeline

Its caveat is SIGPIPE: producer | head -5 returns 141 once head exits early. Add || true where early termination is expected.

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.

Terminal window
shopt -s inherit_errexit # subshells inherit set -e (bash 4.4+)
shopt -s nullglob # non-matching globs expand to nothing, not to themselves
shopt -s failglob # non-matching globs are an error (good in scripts)
shopt -s globstar # enable ** for recursive globbing (bash 4.0+)

Six rules that eliminate most bugs:

  1. Double-quote every $var and $(cmd) — no exceptions until you can name the reason.
  2. Use "$@", never $* or $@, to forward arguments.
  3. Use "${arr[@]}" to expand arrays.
  4. Use arrays, never strings, to build command lines with optional arguments.
  5. Prefer ${var:-} under set -u for anything optional.
  6. Terminate option parsing with -- before user data: rm -- "$file", grep -- "$pat" file.
Terminal window
# building a command safely
cmd=(rsync -a)
[[ -n "${DRY_RUN:-}" ]] && cmd+=(--dry-run)
[[ -n "${EXCLUDE:-}" ]] && cmd+=(--exclude="$EXCLUDE")
"${cmd[@]}" -- "$src" "$dst"

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 bash
set -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 $tmpdir is 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.
Terminal window
cleanup() {
local status=$? # capture BEFORE running anything else
rm -rf "$tmpdir"
[[ $status -ne 0 ]] && echo "failed with status $status" >&2
return $status
}
trap cleanup EXIT
trap 'echo "interrupted" >&2; exit 130' INT TERM

An ERR trap reports the failing line; add set -E so it is inherited by functions and subshells:

Terminal window
set -eE
trap 'echo "error on line $LINENO: $BASH_COMMAND" >&2' ERR

Other 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).

getopts is a builtin, POSIX, and handles bundling (-abc) and attached values (-ofile). It does not support long options.

#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: ${0##*/} [-v] [-n COUNT] [-o FILE] FILE...
-v verbose
-n COUNT number of iterations (default 1)
-o FILE output file
-h show this help
EOF
}
verbose=0
count=1
outfile=""
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 ;;
esac
done
shift $(( 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.

For --long-form options, hand-roll the loop. More code, more control.

Terminal window
verbose=0
count=1
outfile=""
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 ;;
esac
done

Handling -- 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.

Validate at the boundary, then trust the values internally.

Terminal window
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" ;;
esac

For paths supplied by users, reject traversal explicitly rather than trying to sanitize:

Terminal window
[[ "$name" == */* ]] && die "name must not contain a path separator"
[[ "$name" == .* ]] && die "name must not start with a dot"

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.

Terminal window
sudo apt install shellcheck # or: brew install shellcheck
shellcheck myscript.sh
shellcheck -s bash -S warning myscript.sh # force dialect, minimum severity
shellcheck ./*.sh

Frequent 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:

Terminal window
# shellcheck disable=SC2016 # single quotes are intentional; awk uses $1
awk '{print $1}' file

Add 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.

Terminal window
bash -n script.sh # syntax check only, runs nothing
bash -x script.sh # print each command after expansion, as it runs
bash -xv script.sh # -v also prints the raw source lines

Turn tracing on for a region rather than the whole script:

Terminal window
set -x
suspicious_function "$arg"
set +x

Make the trace readable by including location information in PS4:

Terminal window
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x

Output 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:

Terminal window
exec 5> /tmp/trace.log
BASH_XTRACEFD=5
set -x

A debug flag driven by the environment is a good habit:

Terminal window
[[ -n "${DEBUG:-}" ]] && set -x
# run as: DEBUG=1 ./script.sh

Other 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.

Filenames may contain spaces, newlines, quotes, and leading dashes. Only two things are forbidden: the NUL byte and /. Every safe technique follows from that.

Terminal window
# iterate: use a glob, not `ls`
for f in ./*.txt; do
[[ -e "$f" ]] || continue # or shopt -s nullglob
process -- "$f"
done
# recurse: NUL-separated
while IFS= read -r -d '' f; do
process -- "$f"
done < <(find . -type f -print0)
# feed another command
find . -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 -rf becomes an option to whatever runs next.
  • Use -- before user-supplied arguments. rm -- "$f", grep -e "$pat" -- "$f".
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"
template.sh
#!/usr/bin/env bash
#
# Short description of what this script does.
set -euo pipefail
shopt -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 <<EOF
Usage: $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.

  • set -euo pipefail catches 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"' EXIT right after mktemp -d is the highest-value line in most scripts.
  • Quote everything; use arrays for command lines; never eval user input.
  • getopts for 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 a PS4 that shows file and line.
  • Filenames are hostile: -print0/read -d '', ./ prefixes, -- terminators, never parse ls.