Shell Scripting Fundamentals

Quoting: the single biggest source of shell script bugs

name="John Smith"
echo $name      # prints: John Smith  (looks fine here)
echo Hi $name   # prints: Hi John Smith  (still looks fine)

for word in $name; do echo "[$word]"; done
# prints: [John]
#         [Smith]     <- word-split into TWO iterations, not one

Unquoted variable expansion undergoes word splitting (breaking on whitespace) and globbing (expanding * as a filename pattern) — $name isn't substituted as one atomic string, it's substituted and then re-parsed as if it were typed directly. This is invisible for simple cases (a single command argument that happens not to contain spaces) and breaks the moment a variable actually contains a space, a *, or is empty.

for word in "$name"; do echo "[$word]"; done
# prints: [John Smith]     <- one iteration, correct

Double-quoting a variable expansion ("$name") prevents both word splitting and globbing while still expanding the variable's value — this is why "$var" should be the default habit for essentially every variable reference in a script, not an occasional defensive measure. Single quotes ('$name') suppress expansion entirely — the variable name is printed literally, not its value — useful specifically when a literal $ needs to reach the output unexpanded.

Exit codes and set -euo pipefail

Every command returns an exit code: 0 means success, non-zero means failure (the specific non-zero value's meaning is command-specific). By default, a bash script keeps running after a command fails — silently, unless something explicitly checks $? (the previous command's exit code).

#!/usr/bin/env bash
set -euo pipefail
  • set -e — exit the script immediately if any command fails (returns non-zero), instead of continuing past it silently.
  • set -u — treat referencing an undefined variable as an error, instead of silently substituting an empty string (which can turn a typo'd variable name into a script that runs with unintended empty arguments rather than failing loudly).
  • set -o pipefail — without this, a pipeline (cmd1 | cmd2)'s exit code is only cmd2's — cmd1 failing is invisible if cmd2 still exits 0. pipefail makes the whole pipeline's exit code reflect the first failure anywhere in it.

This three-flag combination is close to a mandatory default for any script beyond a few trivial lines — without it, a script silently continuing past a failed step (a failed cd, a failed download) and then doing something destructive with wrong or missing data is a common, hard-to-diagnose failure mode.

Conditionals and loops

if [ -f "$file" ]; then
    echo "exists"
elif [ -d "$file" ]; then
    echo "is a directory"
else
    echo "does not exist"
fi

for f in *.log; do
    echo "processing $f"
done

while read -r line; do
    echo "line: $line"
done < input.txt

[ -f "$file" ] (or the more modern [[ -f "$file" ]], which handles some edge cases like empty variables more gracefully and doesn't need manual quoting protection to the same degree) tests file conditions — -f (regular file exists), -d (directory exists), -z (string is empty), -n (string is non-empty) are the common ones. while read -r line is the standard way to process a file line-by-line; the -r flag prevents backslashes in the input from being interpreted as escape sequences, which is almost always the intent when reading arbitrary text data.

Functions

deploy() {
    local env="$1"
    local version="$2"
    echo "deploying $version to $env"
}

deploy "staging" "v1.2.3"

local scopes a variable to the function — without it, every variable in a bash function is global by default, which is a common source of one function's internal variable accidentally clobbering another function's variable of the same name elsewhere in a larger script.

Common pitfall

Forgetting to quote a variable that holds a filename with a space (or that could be empty) is the single most common way a shell script works perfectly in every test the author tries and then breaks on a real input — rm -rf $dir/* with an unset, empty $dir expands to rm -rf /* (deleting from the filesystem root) rather than failing loudly, precisely because an empty unquoted variable simply disappears from the command line instead of being passed as an empty argument. set -u (treating unset variables as errors) and consistently quoting ("$dir") together close this specific, genuinely dangerous class of bug.