100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Bash

Defensive Bash Scripting

Techniques for writing bash scripts that fail safely, validate their inputs, and avoid the common footguns that turn a small bug into a production incident.

Production BashIntermediate10 min readJul 10, 2026
Analogies

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.

bash
#!/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 pipefail or 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

Was this page helpful?

Topics covered

#Bash#AdvancedBashScriptingStudyNotes#DevOps#DefensiveBashScripting#Defensive#Scripting#Means#Validating#StudyNotes#SkillVeris