Shell Scripting
set -euo pipefail, trap, subshells — and when to graduate to Python.
Shell Scripting
Bash is the wrong language for almost every problem, and yet it's the right one for almost every "glue these tools together" task. The skill is knowing where the line is.
Analogy
Bash is duct tape. Excellent for repairing a hose, holding a sign up, or temporarily lashing two things together. Useless for building a chair. The mistake everyone makes once is using duct tape to build the chair, then pretending the chair is fine because it usually doesn't collapse.
The four-line opener
Every bash script worth committing starts with the same four lines:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
trap 'rm -rf "$WORKDIR"' EXIT
Take it apart:
#!/usr/bin/env bash— find bash on the user's PATH, not a hard-coded/bin/bash. Friendlier to macOS (where/bin/bashis ancient) and to Nix.set -e— abort on any command that returns non-zero (with caveats — see below).set -u— error on undefined variable references. Catches a huge class of typo bugs.set -o pipefail— a pipeline's exit code is the first non-zero, not the last. Without it,false | truereturns 0 and your "set -e" silently does nothing.IFS=$'\n\t'— the internal field separator. By default it's space-tab-newline, which silently splits filenames containing spaces. Newline-tab-only is safer.trap '...' EXIT— register cleanup that runs on every exit path. The bash version ofdefer/finally.
set -e is not as strong as you think
set -e aborts on the first non-zero exit, except in many surprising places:
- The condition of an
if,while, oruntil. - The left side of
&&/||. - The body of a function called with
||. - A pipeline (only the last command's exit matters — that's why you also need
pipefail).
When you genuinely want a non-zero exit to be ignored:
grep PATTERN file || true # match-or-not, never abort
mkdir -p "$dir" || : # `:` is the bash no-op
Use these explicitly. If you need if semantics, write if grep -q ...; then.
trap — cleanup that always runs
The mental model: trap '<command>' SIGNAL[ SIGNAL ...]. The most common signal is the pseudo-EXIT, which fires on any exit path — success, failure, or kill.
WORKDIR=$(mktemp -d)
trap 'rm -rf "$WORKDIR"' EXIT
That two-liner is the safest temp-dir pattern in shell. Without it, a script that crashes leaks a /tmp/tmp.XXXX directory every run.
For Ctrl-C semantics:
trap 'echo interrupted; exit 130' INT TERM
Stack traps if you want both. Don't quote the command with double quotes if it contains variables — single quotes defer expansion to trap-time, double quotes expand at trap-set time, which usually isn't what you want.
Subshells vs grouping
Two ways to "do these commands together":
( cd /tmp ; rm temp* ) # subshell: forks a child, parent unaffected
{ cd /tmp ; rm temp* ; } # group: same shell, semicolon + spaces matter
Subshells (( ... )) isolate state — cd, variable changes, traps — from the parent. Useful when you want to mess with the environment temporarily and not leak.
Groups ({ ...; }) are pure syntactic grouping. Same shell, same state. The trailing ; (or newline) before the } is mandatory; the spaces inside are mandatory.
{ command1; command2; command3; } > combined.log # redirect grouped output
Functions, arrays, and the "$@" rule
greet() {
local name=$1
echo "hi, $name"
}
greet "world"
Two rules to internalise:
- Always use
localfor function-internal variables. Otherwise everything is global. - Always pass and forward arguments as
"$@"(with the quotes). Bare$@re-splits onIFS, breaking any argument that contains spaces.
forward() {
inner_command "$@" # CORRECT — preserves caller's word boundaries
}
Arrays exist (arr=(a b c), ${arr[0]}, ${arr[@]}) but the syntax is rough. If you need anything beyond a flat list of strings, that's the signal to leave bash.
ShellCheck — the linter that catches everything
If you write any non-trivial bash, shellcheck script.sh is non-negotiable. It catches:
- Unquoted variable expansions that would break on filenames with spaces.
cd $foo &&patterns where the cd silently fails.- Useless cats, useless echos, useless backticks.
- Subtle
[ ... ]vs[[ ... ]]differences. - Many more — every category is documented at shellcheck.net.
Treat ShellCheck warnings the way you'd treat TypeScript errors: zero tolerance.
When to leave bash
A reasonable threshold: you've passed the point of bash usefulness when any of these is true:
- The script is past ~200–500 lines.
- You need nested data structures (a map of maps, an object with named fields).
- You need real testing (mocking, fixtures, parameterised cases).
- You need typed function signatures.
- You're parsing JSON or YAML inline. (jq buys you a few rounds, but hits a wall.)
- You're handling concurrency beyond
&andwait.
The escape hatch is Python (or Go for distributed binaries). Both are install-anywhere, both have rich stdlib, both are far easier to test and maintain.
A 500-line bash script is technical debt. A 200-line Python script doing the same job is a tool you'll keep using.
The 10-line CLI tool that's fine in bash
#!/usr/bin/env bash
# usage: bytes <file>
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $(basename "$0") <file>" >&2
exit 64 # EX_USAGE
fi
stat -c %s "$1"
This is a pure bash sweet spot — wraps one syscall, takes one argument, exits cleanly. There's no reason to reach for Python here.
The trap-as-mutex idiom
A neat use of trap that doesn't get talked about: use a lock file with trap to ensure only one instance runs:
LOCK=/tmp/myscript.lock
( set -o noclobber; > "$LOCK" ) || { echo "already running"; exit 1; }
trap 'rm -f "$LOCK"' EXIT
# ... real work ...
set -o noclobber makes > "$LOCK" fail if the file exists. The trap removes it on every exit path, so a crashed run doesn't leave a stale lock forever.
(flock(1) is the more robust answer for production — atomic, safe across reboots, integrates with cron.)
Common bugs
Unquoted variables containing spaces. mv $foo bar breaks if $foo is "hello world". Always quote: mv "$foo" bar. ShellCheck flags this.
Using [ ] instead of [[ ]]. [[ ]] is bash-specific but vastly safer (no word splitting, supports =~ regex, supports && || directly). Prefer it unless you need POSIX portability.
echo for anything but plain strings. echo interprets backslashes inconsistently across shells. For anything with escape sequences, use printf '%s\n' "$x".
Reading a file with for line in $(cat file). This splits on whitespace, not newlines. Correct: while IFS= read -r line; do ...; done < file.
Forgetting -- before user-supplied filenames. rm "$file" happily executes rm -rf if $file starts with -. Use rm -- "$file".
Pick up these reflexes early; they pay back forever.
Tools in the wild
4 tools- cliShellCheckfree tier
Static analyser for shell scripts — catches the entire class of bugs you'd otherwise hit at 3am.
- clishfmtfree tier
Bash formatter; pairs with ShellCheck for a clean, consistent style.
- clibashatefree tier
Style-only bash linter — opinionated but useful when you have many authors.
- clibats-corefree tier
TAP-style unit tests for bash; if your script needs tests, your script needs Python.