What Defensive Scripting Means in Bash
Defensive bash scripting is the practice of assuming every input, environment variable, and command result is untrustworthy until proven otherwise, because bash's default behavior is dangerously permissive: unset variables silently expand to empty strings, failed commands don't stop the script, and unquoted expansions can turn a single filename into multiple arguments. A defensively written script validates its arguments, quotes every expansion, checks every critical command's exit status, and fails loudly and early rather than continuing to run on corrupted assumptions.
Cricket analogy: It's like a captain refusing to set an aggressive field until confirming the pitch report and toss result, rather than assuming conditions favor pace bowling by default.
Validating Inputs and Quoting Everything
Every positional parameter, command substitution, and variable expansion should be double-quoted ("$var", not $var) unless you deliberately want word splitting, because an unquoted variable containing spaces or glob characters will be split and re-globbed by the shell, silently turning rm $file into a multi-file deletion if $file contains a space. Defensive scripts also validate argument counts and types explicitly with a usage function, rather than letting a missing argument propagate as an empty string into a destructive command like rm -rf "$dir"/*, which becomes rm -rf /* when $dir is unset.
Cricket analogy: Quoting a variable is like a fielder cleanly gathering the ball with both hands before the throw, instead of a one-handed grab that risks the ball squirting away unpredictably.
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 <backup-dir> <target-dir>" >&2
exit 2
}
[[ $# -eq 2 ]] || usage
backup_dir="$1"
target_dir="$2"
# Refuse to operate on unset or root-like paths
[[ -n "$backup_dir" && "$backup_dir" != "/" ]] || { echo "refusing: unsafe backup_dir" >&2; exit 1; }
[[ -d "$target_dir" ]] || { echo "error: $target_dir is not a directory" >&2; exit 1; }
rsync -a --delete "$target_dir/" "$backup_dir/"Checking Exit Statuses and Avoiding Silent Failures
Bash only stops a pipeline on a non-zero exit if you opt in, and even then only the last command's status is checked by default; a command earlier in a pipe (like curl ... | grep ...) can fail silently while grep still exits 0 because it simply found nothing to match. Defensive scripts either check ${PIPESTATUS[@]} explicitly after a pipeline or set set -o pipefail so any failing stage in the pipe causes the whole pipeline to report failure, and they check exit codes of anything whose failure would corrupt downstream state, rather than trusting that 'the command ran' means 'the command succeeded'.
Cricket analogy: It's like a third umpire checking every stage of a boundary catch — the initial take, the foot near the rope, the throw-up after falling — instead of only confirming the final celebration.
pipefail combined with set -e is not automatically transitive into functions called from a pipeline in older bash versions — test your specific bash version's behavior, and consider checking ${PIPESTATUS[@]} explicitly in scripts that must run identically across bash 3.2 (macOS default) through 5.x.
Never write destructive commands like rm -rf "$dir"/* without first validating $dir is non-empty and not a root-level path. If $dir is unset due to a typo or missing set -u, the expansion collapses to rm -rf /*, which on a permissive user can destroy the filesystem.
- Defensive scripting treats all input as untrusted: validate argument count, type, and safety before acting on it.
- Double-quote every variable expansion and command substitution to prevent word splitting and glob re-expansion.
- Write an explicit usage() function and call it on invalid arguments instead of letting bad input silently propagate.
- Use
set -o pipefailor check${PIPESTATUS[@]}because only the last command's exit status is checked in a pipeline by default. - Guard destructive commands (rm -rf, mv, dd) with explicit checks that the target path is non-empty and not a dangerous root-level path.
- Fail loudly and early — a script that continues after silent corruption is far more dangerous than one that stops immediately.
- Test edge cases explicitly: empty arguments, paths with spaces, and unset environment variables are the most common real-world failure triggers.
Practice what you learned
1. What happens when an unquoted variable containing a space is expanded in a command like `rm $file`?
2. By default, what exit status does a bash pipeline report if an earlier command fails but the last command succeeds?
3. What is the danger of writing `rm -rf "$dir"/*` without validating `$dir` first?
4. What does `set -o pipefail` change about pipeline behavior?
5. Why should a defensive script call an explicit usage() function on invalid arguments rather than proceeding with defaults?
Was this page helpful?
You May Also Like
Bash Strict Mode
An explanation of the `set -euo pipefail` pattern known as bash strict mode, what each flag actually does, and where its edge cases can still surprise you.
Bash Script Debugging Techniques
Practical techniques for finding and fixing bugs in bash scripts, from tracing execution with set -x to using ShellCheck and traps for structured diagnostics.
Writing Portable Shell Scripts
How to write shell scripts that behave consistently across bash versions, macOS vs Linux, and POSIX sh, avoiding the common traps that break scripts on a different machine.
Related Reading
Related Study Notes in DevOps
Browse all study notesNginx Study Notes
DevOps · 30 topics
DevOpsAnsible Study Notes
DevOps · 30 topics
DevOpsAdvanced Kubernetes Study Notes
Kubernetes · 30 topics
DevOpsApache Kafka Study Notes
Kafka · 30 topics
DevOpsDocker & Kubernetes Study Notes
YAML · 40 topics
DevOpsCI/CD Tools & Pipelines Study Notes
YAML · 37 topics