Foundation · Core

Shell & Bash

How the shell reads, expands and executes a line — and why nearly every shell bug is really a quoting bug at the word-splitting stage.

23 min read Level: core → advanced Foundation 06 / 10
The model

WHAT HAPPENS TO A LINE

Five stages, in a fixed order, before your command ever runs.

1 · READRead a lineTokeniseParse into commands2 · EXPANDBrace {a,b}Tilde ~Parameter $VARCommand $(…)Arithmetic $((…))3 · SPLITWord splitting on $IFSPathname glob *Quote removal4 · REDIRECT< > >> 2>&1Pipes |Here-docs <<EOF5 · EXECUTEBuiltin?Function?fork + execvewait / $?

Word splitting and globbing happen after variable expansion. Double quotes suppress exactly those two steps — which is the whole reason to use them.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.

Low levelWhat does bash do to your line before anything runs?
ONE COMMAND LINE, EXPANDEDthe line you typedIFS, shopt, set -fargv[] for execvefds 0/1/2PARSEtokenisealias expansionfirst word onlyquote removal is LASTafter every expansionEXPAND, IN THIS ORDERbrace {a,b}before variablestilde ~parameter $varunquoted → splitscommand $( )THENarithmetic $(( ))word splitting on IFSthe classic bugpathname globbing *can match nothingredirectionbefore exec
The order is the whole lesson. Word splitting happens AFTER variable expansion and BEFORE quote removal, which is exactly why $file breaks on a space and "$file" does not.
ConnectionWhat actually happens when you type a | b > out?
bash reads the linepipe(2)two fdsfork for 'a'child 1dup2 → stdoutfd 1 = pipe writefork for 'b'child 2open 'out', dup2fd 1 = fileexecve bothargv from expansionwait, $? = lastPIPESTATUS has all
Both children are forked before either runs, which is why a pipeline's exit status is the LAST command's — and why set -o pipefail exists at all.
Core

CORE CONCEPTS

Expansion, exit codes, redirection, conditionals.

Almost every surprising shell behaviour comes from not knowing this order. The shell performs, strictly in sequence:

  1. Brace expansion — {a,b}
  2. Tilde expansion — ~
  3. Parameter and variable expansion — $VAR
  4. Command substitution — $(…)
  5. Arithmetic expansion — $((…))
  6. Word splitting on $IFS
  7. Pathname expansion (globbing)
  8. Quote removal

Steps 6 and 7 happen after your variable has been substituted. That is the entire explanation for the filename-with-spaces bug: the shell substitutes my report.pdf, then splits it into two words, then hands rm two arguments that don't exist.

Double quotes suppress steps 6 and 7. That is what they are for. Quote every expansion unless you have a specific reason not to.

the same command, quoted and not
$ f='my report.pdf'
$ rm $f
rm: cannot remove 'my': No such file or directory
rm: cannot remove 'report.pdf': No such file or directory
$ rm "$f"
One argument. This is the whole lesson.
Arrays need the same care — "${a[@]}" keeps elements intact:
$ files=("my report.pdf" "notes 2.txt")
$ printf "%s\n" "${files[@]}"
my report.pdf
notes 2.txt

$? holds the exit status of the last command: 0 for success, 1–255 otherwise. By convention 1 is a general error, 2 is a usage error, 126 means found but not executable, 127 means not found, and 128+N means killed by signal N — so 137 is SIGKILL (128+9), which is what an OOM kill looks like from the outside.

The pipeline trap

In a | b | c, $? is c's exit status. If a fails and c succeeds, the pipeline reports success. A backup script of the form pg_dump … | gzip > backup.gz will happily report success while writing a perfectly valid gzip of an error message.

set -o pipefail makes the pipeline return the rightmost non-zero status. ${PIPESTATUS[@]} gives you every stage's status individually.

the backup that silently wasn't
$ pg_dump missing_db | gzip > backup.sql.gz; echo $?
pg_dump: error: connection to database "missing_db" failed
0
Exit 0. The cron job is 'green'. The backup is 20 bytes of nothing.
$ set -o pipefail
$ pg_dump missing_db | gzip > backup.sql.gz; echo $?
1
$ echo "${PIPESTATUS[@]}"
1 0
Stage 1 failed, stage 2 succeeded. Now you know which.

Every process starts with three descriptors: 0 stdin, 1 stdout, 2 stderr. Redirection rewires them before the command runs.

Order matters, and it is the reverse of how people read it. >file 2>&1 first points 1 at the file, then points 2 at wherever 1 is now — the file. Both go to the file. 2>&1 >file first points 2 at wherever 1 currently is — the terminal — and then moves 1 to the file. stdout goes to the file, stderr still goes to the terminal. This is the single most common shell redirection bug.

FormEffect
> fstdout to f, truncating
>> fstdout to f, appending
2> fstderr to f
&> f / > f 2>&1both to f (bash)
2>&1 > fstdout to f, stderr to terminal — usually a bug
> /dev/null 2>&1discard everything
< fstdin from f
<<< 'str'here-string — feed a literal to stdin
<(cmd)process substitution — a command's output as a filename
exec 3> fopen fd 3 for the rest of the script

[ is a command — historically /usr/bin/[. Its arguments go through word splitting and globbing like any other command's, which is why an unquoted empty variable turns [ $x = y ] into [ = y ] and a syntax error.

[[ ]] is a bash keyword. The shell parses it specially: no word splitting, no globbing on the left side, and it adds =~ for regex and &&/|| inside. Use it in bash, always.

(( )) is arithmetic evaluation: bare variable names, C-style operators, and an exit status that is 0 when the expression is non-zero — the opposite of every other exit code convention, which trips people up exactly once.

why [[ ]] is worth the non-portability
$ x=
$ [ $x = foo ] && echo yes
bash: [: =: unary operator expected
$ [[ $x = foo ]] && echo yes
No error. No output. Correct.
$ [[ $version =~ ^v([0-9]+)\.([0-9]+) ]] && echo "major ${BASH_REMATCH[1]}"
major 2
$ (( count > 10 )) && echo busy
No $ needed, and it reads like arithmetic because it is.
Advanced

ADVANCED

Strict mode's blind spots, traps, process substitution, parallelism.

set -e exits on an unhandled non-zero status. set -u errors on an unset variable. set -o pipefail propagates pipeline failures. Together they turn a script that limps on after an error into one that stops. Every ops script should start with them.

Three places -e does not fire

  • Inside a condition. if cmd; then, cmd && …, ! cmd — the failure is being tested, so it is not an error. Correct, but it means a function called from an if loses errexit throughout its body.
  • Command substitution in an assignment. local x=$(failing) succeeds, because local succeeded. Split the declaration from the assignment.
  • Anything but the last command in a pipeline — unless pipefail is set.
the one that gets everyone
$ set -euo pipefail
$ get_version() { cat /nonexistent; }
$ main() { local v=$(get_version); echo "got [$v]"; }
$ main
cat: /nonexistent: No such file or directory
got []
Still running, with an empty value. 'local' returned 0.
Split it, and -e does its job:
$ main() { local v; v=$(get_version); echo "got [$v]"; }

trap 'handler' EXIT runs the handler however the script ends — normal exit, error under set -e, or a caught signal. It is the shell's finally, and it is the right place for every temp file, lock and mount you created.

Trapping INT TERM as well lets you clean up when someone hits Ctrl-C or the orchestrator sends SIGTERM. In a container, a script that traps TERM and forwards it to its child is the difference between a two-second shutdown and the full 30-second grace period.

a cleanup that actually runs
$ #!/usr/bin/env bash
$ set -euo pipefail
$ tmp=$(mktemp -d)
$ cleanup() { rm -rf "$tmp"; [[ -n ${child:-} ]] && kill "$child" 2>/dev/null || true; }
$ trap cleanup EXIT INT TERM
$ long_running_thing > "$tmp/out" &
$ child=$!
$ wait "$child"
Ctrl-C, SIGTERM, an error, or success — the temp dir goes away and
the child gets signalled. One trap line covers all four paths.

<(cmd) runs cmd and substitutes the path of a file descriptor (/dev/fd/63) that reads its output. Anything expecting a filename now accepts a command. No temp file, no cleanup, no race.

The classic use is comparing two things that aren't files — the output of a command on two hosts, a sorted list against another sorted list, a config as deployed versus as intended.

comparing two live states
$ diff <(ssh web-1 'rpm -qa | sort') <(ssh web-2 'rpm -qa | sort')
> nginx-1.24.0-1.el9.x86_64
web-2 has a package web-1 doesn't. No temp files were involved.
Also solves the classic subshell-loses-variables problem:
$ count=0; find . -name '*.log' | while read -r f; do ((count++)); done; echo $count
0
The pipeline put the loop in a subshell — the increment was lost.
$ count=0; while read -r f; do ((count++)); done < <(find . -name '*.log'); echo $count
1842

A for loop over 400 hosts running one SSH command each, serially at two seconds apiece, is thirteen minutes. xargs -P 20 makes it forty seconds, with a bounded concurrency you control.

-P N sets parallelism, -n 1 passes one argument per invocation, and -0 with find -print0 handles filenames with spaces and newlines safely. For anything more complex, GNU parallel adds per-job output grouping — which matters, because interleaved output from 20 concurrent jobs is unreadable.

bounded parallel work
$ cat hosts.txt | xargs -P 20 -n 1 -I{} ssh {} 'uptime'
Filenames done safely — NUL-separated, never split on whitespace:
$ find . -name '*.log' -print0 | xargs -0 -P 8 -n 50 gzip
GNU parallel keeps each job's output together instead of interleaved:
$ parallel -j 20 --tag ssh {} uptime :::: hosts.txt
Bound it, always

-P 0 means unlimited. Point that at 4,000 hosts and you will exhaust file descriptors, fork bomb the box, or get rate-limited by whatever you're talking to. Pick a number, and pick it based on what the far side can take.

In practice

A SCRIPT SKELETON

The skeleton below is worth keeping as a template. Every line in it exists because of a specific class of production failure.

a script that fails properly
$ #!/usr/bin/env bash
env bash, not /bin/bash — macOS and BSD put it elsewhere
$ set -euo pipefail
$ IFS=$'\n\t'
drop space from IFS: filenames with spaces stop splitting
$ readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
$ readonly LOG_TAG="${0##*/}"
$ log() { printf "%s [%s] %s\n" "$(date -Is)" "$LOG_TAG" "$*" >&2; }
$ die() { log "FATAL: $*"; exit 1; }
log to stderr so stdout stays clean and pipeable
$ tmp="$(mktemp -d)"
$ trap 'rm -rf "$tmp"' EXIT INT TERM
$ [[ $# -ge 1 ]] || die "usage: $LOG_TAG <target>"
$ command -v jq >/dev/null || die "jq is required"
$ main() {
$ local target="$1"
$ log "starting on $target"
$ ...
$ }
$ main "$@"
Run shellcheck in CI

ShellCheck catches unquoted expansions, the 2>&1 ordering bug, useless cat, subshell variable loss, and about two hundred other things — all statically, in under a second. There is no reason for a shell script in a repo not to be passing it.

Reference

CHEATSHEET

IdiomWhat it does
set -euo pipefailStop on error, on unset var, and on any pipeline stage failing
"${var:-default}"Value, or a default if unset or empty
"${var:?message}"Value, or exit with that message if unset
"${var%%.*}"Strip the longest trailing match — a.b.ca
"${var##*/}"Strip the longest leading match — basename, without forking
"${var//old/new}"Replace all occurrences, no sed needed
"${#var}"String length
"${arr[@]}"All array elements, each kept as one word
${PIPESTATUS[@]}Exit status of every stage in the last pipeline
trap 'cleanup' EXIT INT TERMRun cleanup however the script ends
mktemp -dRace-free temp directory
<(cmd)Command output where a filename is expected
while read -r l; do …; done < <(cmd)Loop without losing variables to a subshell
find … -print0 | xargs -0 -P NParallel, safe with any filename
command -v tool >/dev/nullPortable 'is this installed'
exec 200>/var/lock/f; flock -n 200Don't let two copies run at once
shellcheck script.shStatic analysis. Run it in CI