Bash-specific guidance beyond the cross-cutting rules in
shell-standards.md: error handling, portability, arrays,
and safe temp files. Snippets cite the
Bash reference manual and
ShellCheck codes.
set -euo pipefail is the floor. Two additions earn their keep:
-
trap ... ERRto report where a script died:trap 'echo "failed at line $LINENO" >&2' ERR
-
Explicit checks for the failures
set -edeliberately ignores — a command in anif, a&&/||chain, or the left side of a pipe (mitigated bypipefail). Do not paper over these with|| true.
Scripts that "work on my machine" often assume GNU coreutils. macOS ships Bash 3.2 (2007) and BSD userland. Guard against both:
| Concern | GNU (Linux) | BSD (macOS) | Portable approach |
|---|---|---|---|
| In-place sed | sed -i |
sed -i '' |
write to a temp file, then mv |
| Canonical path | readlink -f |
(absent) | a small cd/pwd helper, or realpath if present |
| Associative arrays | declare -A (Bash 4+) |
not in 3.2 | avoid, or require Bash 4 explicitly |
mapfile/readarray |
Bash 4+ | not in 3.2 | while IFS= read -r loop for 3.2 |
If you require Bash 4+, assert it early and fail with a clear message rather than misbehaving:
if ((BASH_VERSINFO[0] < 4)); then
echo "This script needs Bash 4+ (found $BASH_VERSION)." >&2
exit 1
fi- Build lists as arrays; expand quoted:
"${arr[@]}". A bare$arris only the first element (SC2128). - Read lines with
mapfile -t arr < file, notarr=($(cat file)), which word-splits and globs (SC2207). - Forward a script's own arguments verbatim with
"$@"(SC2068flags the unquoted form).
See the runnable pairs in examples/bash/.
By default, a glob that matches nothing remains unchanged. In an empty
directory, for file in *.txt therefore runs once with the literal string
*.txt, even though no file exists.
- Enable
nullglobwhen no matches should produce zero words:shopt -s nullglob. This is useful for loops and arrays that should simply stay empty. - Enable
failglobwhen no matches should be an error instead. This is useful when the missing input signals a broken assumption.
Both options are Bash-specific and affect subsequent expansions in the current
shell, so enable them deliberately and keep their scope narrow. See the Bash
manual's Filename Expansion
section and the runnable pair in examples/bash/.
Create with mktemp, remove with an EXIT trap set immediately afterward. Keep
the variable at script scope so the trap can still see it after a function
returns:
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXITBash's own builtins tell you how a name resolves and let you bypass shadowing — no external tools needed. (Bash builtins)
command -v foo— portable existence check: prints the path (or nothing) and returns non-zero if absent. Prefer it overwhich, an external that is not always installed.command foo— run the external or builtinfoo, ignoring any shell function or alias of the same name.builtin cd …— force the builtin when a function namedcdwraps it; the standard way to write such a wrapper without infinite recursion.type -t foo— reportsalias,keyword,function,builtin, orfile, so you can branch on how a name would run;type -a foolists every match.help [pattern]— built-in documentation for builtins (help test,help printf), no man page required.
This repo verifies every *.good.sh with bats
(tests/examples.bats). For your own scripts, bats keeps behavioural tests close
to the code; run them in CI alongside shellcheck and shfmt.