unix · level 8

Shell Scripting

set -euo pipefail, trap, subshells — and when to graduate to Python.

200 XP

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/bash is 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 | true returns 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 of defer / 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, or until.
  • 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 local for function-internal variables. Otherwise everything is global.
  • Always pass and forward arguments as "$@" (with the quotes). Bare $@ re-splits on IFS, 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 & and wait.

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
  • ShellCheckfree tier

    Static analyser for shell scripts — catches the entire class of bugs you'd otherwise hit at 3am.

    cli
  • shfmtfree tier

    Bash formatter; pairs with ShellCheck for a clean, consistent style.

    cli
  • bashatefree tier

    Style-only bash linter — opinionated but useful when you have many authors.

    cli
  • bats-corefree tier

    TAP-style unit tests for bash; if your script needs tests, your script needs Python.

    cli