Bash Scripting Fundamentals
Bash is the lingua franca of Unix automation. Every Linux server you'll ever touch has it, and most administrative tooling — installers, CI scripts, container entrypoints — is written in it. The catch: Bash is permissive by default. It happily runs scripts that silently corrupt files because a single variable was empty, or because a pipeline failed halfway through and nobody noticed. This guide is about writing Bash that fails loudly and predictably.
We'll work through the building blocks in order, finishing with a small but real backup-rotation script you can adapt. The examples target Bash 4+ (because we'll use associative arrays and a few modern features); on macOS this means installing GNU bash via Homebrew or running on a Linux host.
Shebang and Strict Mode
Every script starts with a shebang: the magic #! line that tells the kernel which interpreter to use. Prefer /usr/bin/env so the script finds whatever bash is first on the user's PATH — important on macOS and BSDs where the system bash is ancient.
Script header we use every time
#!/usr/bin/env bash # # backup-rotate.sh — keep N most recent backups of a directory # Author: alex # Requires: bash 4+, GNU coreutils set -Eeuo pipefail IFS=$'\n\t'
That set line is "strict mode". Each flag earns its keep:
-e— Exit immediately if any command returns non-zero. Stops the "we kept going after the error" bug class.-E— MakeERRtraps inherit into functions and subshells. Pair withtrapfor useful error messages.-u— Treat unset variables as an error. Catches typos like$PHATH.-o pipefail— A pipeline's exit code is the rightmost non-zero exit. Without this,false | trueexits 0 and you never know the left side failed.
The IFS=$'\n\t' line replaces the default word-splitting characters (space, tab, newline) with just newline and tab. The practical effect: filenames with spaces stop turning into multiple arguments when unquoted. It is not a substitute for proper quoting, but it removes a footgun.
Caveat: Strict mode interacts with arithmetic and certain conditionals. let x=0 and (( x=0 )) return non-zero when the result is zero — they will trip set -e. Use (( x = 0 )) || true or just x=0 when the value happens to be zero.
Variables and Quoting
Bash variables are just strings. The only thing standing between your script and word-splitting chaos is double quotes. The rule is simple: always double-quote variable expansions. Single quotes prevent any expansion at all; backticks are deprecated in favour of $().
Quoting in practice
name="Alex Smith"
file="my photos/holiday.jpg"
echo "Hello, $name" # → Hello, Alex Smith
echo 'Hello, $name' # → Hello, $name (single quotes are literal)
# WRONG — splits on spaces, tries to stat two files
ls -l $file
# RIGHT — quoted, treated as one argument
ls -l "$file"
# Default values
greeting="${greeting:-hi}" # use 'hi' if greeting is unset or empty
required="${1:?missing argument}" # error out if $1 is unset/empty
# Length and substring
echo "${#name}" # 10
echo "${name:0:4}" # Alex
# Lower / upper case (bash 4+)
echo "${name,,}" # alex smith
echo "${name^^}" # ALEX SMITH
When in doubt, quote. The shellcheck linter (covered later) will scream at you for unquoted expansions — listen to it.
Positional Arguments
Scripts receive their arguments as positional parameters. The classics:
$0— the script name as invoked$1,$2, … — the first, second, … argument$#— number of arguments"$@"— all arguments, properly quoted (each one a separate word)"$*"— all arguments joined byIFSinto a single word (rarely what you want)$?— exit status of the last command$$— current shell PID
Argument handling boilerplate
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
cat <<EOF
Usage: $(basename "$0") <source> <destination> [count]
Copy <source> to <destination>, keeping at most [count] copies.
EOF
exit 64 # EX_USAGE from /usr/include/sysexits.h
}
(( $# >= 2 )) || usage
src="$1"
dst="$2"
count="${3:-5}" # default to 5 if not provided
echo "Backing up $src to $dst, keeping $count copies."
For non-trivial flag parsing, reach for getopts (POSIX) or shift through the positional list manually. getopts only handles single-letter flags; for GNU-style long options use getopt from util-linux or just hand-parse — flag libraries written in pure bash exist but tend to be more trouble than they're worth.
Arrays
Bash supports indexed arrays (since v2) and associative arrays / maps (since v4). They are one of the few features that genuinely make bash scripts cleaner than chained awk pipelines.
Indexed and associative arrays
# --- Indexed ---
declare -a hosts=(web01 web02 db01)
hosts+=(cache01) # append
echo "${hosts[0]}" # web01
echo "${hosts[@]}" # all elements as separate words
echo "${#hosts[@]}" # length: 4
# Iterate (quote the @, always)
for h in "${hosts[@]}"; do
echo "checking $h"
done
# Slice
echo "${hosts[@]:1:2}" # web02 db01
# --- Associative (bash 4+) ---
declare -A role
role[web01]=frontend
role[db01]=database
for host in "${!role[@]}"; do
printf '%-10s %s\n' "$host" "${role[$host]}"
done
Pitfall: "${array[@]}" expands to N words; "${array[*]}" joins them with the first character of IFS. Use the former for iteration, the latter only when you really want a single joined string.
Conditionals: [[ ]] over [ ]
Bash has three test syntaxes. The portable [ ] (a.k.a. test) is fine, but its quoting rules are surprising and it doesn't understand &&, ||, or regex. Prefer [[ ]] in Bash scripts: it's safer, more readable, and Bash-specific in a script we already wrote in bash.
Test idioms
# String comparison
if [[ "$name" == "alex" ]]; then echo "hi"; fi
if [[ "$name" != "" ]]; then echo "have name"; fi
if [[ -z "$name" ]]; then echo "empty"; fi
if [[ -n "$name" ]]; then echo "non-empty"; fi
# Numeric comparison — use (( )) for integers
if (( count > 5 )); then echo "many"; fi
if (( count == 0 )); then echo "none"; fi
# File tests
if [[ -f /etc/passwd ]]; then echo "file exists"; fi
if [[ -d /var/log ]]; then echo "dir exists"; fi
if [[ -r config.ini ]]; then echo "readable"; fi
if [[ -x ./script.sh ]]; then echo "executable"; fi
# Combined conditions
if [[ -f "$f" && -r "$f" ]]; then ...; fi
if [[ "$env" == "prod" || "$env" == "stage" ]]; then ...; fi
# Pattern matching (glob)
case "$1" in
-h|--help) usage ;;
-v) verbose=1 ;;
--) shift; break ;;
-*) echo "unknown flag: $1" >&2; exit 64 ;;
*) break ;;
esac
# Regex (only in [[ ]])
if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
echo "looks like an IPv4 address"
fi
Arithmetic with (( ))
Integer arithmetic lives inside double parentheses. Inside (( )) variables don't need a $ prefix, and C-style operators work as you'd expect.
Arithmetic examples
count=0 (( count++ )) # post-increment (( total = a + b * c )) (( percent = (used * 100) / total )) # Use $(( ... )) when you need the value, not just the side effect size_kb=$(( size_bytes / 1024 )) remainder=$(( $RANDOM % 10 )) # 0..9 # Watch out for the -e trap: a (( ... )) that evaluates to 0 returns 1 (( i = 0 )) || true
Bash arithmetic is integer-only. For floating point, shell out to awk or bc:
Float math
pi=$(awk 'BEGIN { print 4 * atan2(1, 1) }')
echo "$pi" # 3.14159...
# Or with bc
echo "scale=4; 22/7" | bc # 3.1428
Loops
Three loop forms cover almost everything:
for, while, until
# for-each over a list (with proper quoting)
for f in "$@"; do
echo "processing $f"
done
# C-style for
for (( i = 0; i < 10; i++ )); do
echo "i=$i"
done
# Range expansion (brace expansion — happens before $variable expansion)
for n in {1..5}; do echo "$n"; done
# WARNING: brace expansion does NOT expand variables
start=1; end=5
for n in {$start..$end}; do echo "$n"; done # prints literally {1..5}!
# Use seq or C-style instead:
for (( n = start; n <= end; n++ )); do echo "$n"; done
# while with a condition
count=0
while (( count < 3 )); do
echo "$count"
(( count++ ))
done
# until — like while, but runs *until* the condition is true
until ping -c1 -W1 db01 >/dev/null 2>&1; do
echo "waiting for db01..."
sleep 2
done
Reading from stdin and files
The right way to read a file line-by-line is the slightly verbose but correct idiom below. It preserves leading/trailing whitespace and handles backslashes literally.
Read each line of a file
while IFS= read -r line; do
echo "got: $line"
done < /etc/hosts
# Stream from a command — note the < <(...) process substitution
while IFS= read -r line; do
echo "ip: $line"
done < <(awk '{print $1}' /etc/hosts)
# CSV-style: tab-separated columns into named variables
while IFS=$'\t' read -r name role port; do
echo "$name ($role) on $port"
done < services.tsv
Why not for line in $(cat file)? Because it splits on IFS (whitespace by default), drops empty lines, and glob-expands anything that looks like a glob. The while read idiom does none of those.
Functions
Functions group logic and make scripts testable. Always declare your variables local inside functions — Bash variables are global by default and forgetting local is one of the easiest ways to introduce a Heisenbug.
Function basics
log() {
local level="$1"
shift
printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$level" "$*" >&2
}
require_root() {
if (( EUID != 0 )); then
log ERROR "must run as root"
return 1
fi
}
# Return values: 0 = success, anything else = error. Bash only stores
# a small int — use globals or stdout for richer "return values".
sum() {
local a="$1" b="$2"
echo $(( a + b ))
}
result="$(sum 3 4)" # capture stdout
echo "$result" # 7
Command Substitution and Here-docs
Capture command output with $(...). Avoid the older backtick form — it doesn't nest cleanly and is easy to mis-read.
Substitution and heredoc styles
# Modern: $(...) — nests, quotes cleanly today="$(date +%F)" files="$(find . -type f -name '*.log' | wc -l)" # Here-doc — multi-line literal, supports expansion cat <<EOF > /tmp/report.txt Report for $(hostname) on $today ================================= Found $files log files in $(pwd). EOF # Quoted heredoc tag (<<'EOF') disables expansion — useful for code blobs cat <<'EOF' > /tmp/install.sh #!/usr/bin/env bash echo "$HOME" # this stays literal in the written file EOF # Here-string — pipe a single string to stdin read -r year month day <<< "$(date '+%Y %m %d')" echo "$year / $month / $day"
Traps and Cleanup
If your script creates temp files, holds locks, or spawns background jobs, you need a trap handler so cleanup runs no matter how the script exits.
Idiomatic cleanup pattern
tmpdir="$(mktemp -d)"
cleanup() {
local rc=$?
rm -rf "$tmpdir"
exit "$rc"
}
trap cleanup EXIT INT TERM
# Optional: print a stack trace on errors when we hit set -e
on_err() {
local exit_code=$?
log ERROR "command failed: '$BASH_COMMAND' (line ${BASH_LINENO[0]}, exit $exit_code)"
exit "$exit_code"
}
trap on_err ERR
# ...do work using "$tmpdir"...
EXIT fires on any termination, normal or otherwise. INT handles Ctrl-C. TERM handles a polite kill. The ERR trap (only useful with set -E) fires the moment set -e would have killed the script — perfect for a "where did this die?" message.
Use ShellCheck
ShellCheck (shellcheck.net) is a static analyser specifically for shell scripts. It catches the entire "I forgot to quote" / "I used = in [ ]" / "$(cat file) instead of redirection" family of bugs. Install it on every dev machine and wire it into CI.
Run it locally
# Debian / Ubuntu sudo apt-get install -y shellcheck # RHEL / Rocky / AlmaLinux sudo dnf install -y ShellCheck # macOS brew install shellcheck shellcheck ./backup-rotate.sh # Disable a specific check inline only when you're certain it's a false positive # shellcheck disable=SC2086 echo $intentionally_unquoted
A Complete Example: Backup Rotation
Putting it all together: a script that archives a directory, places the archive in a backups folder, and keeps only the N most-recent archives. It illustrates strict mode, argument parsing, functions, traps, and safe filename handling.
backup-rotate.sh
#!/usr/bin/env bash
#
# backup-rotate.sh — archive SOURCE into DEST and keep KEEP most recent backups
#
# Usage: backup-rotate.sh SOURCE DEST [KEEP]
# Example: backup-rotate.sh /var/www /srv/backups 7
set -Eeuo pipefail
IFS=$'\n\t'
log() {
printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$1" "${*:2}" >&2
}
usage() {
cat <<EOF
Usage: $(basename "$0") SOURCE DEST [KEEP]
SOURCE Directory to archive
DEST Directory to place the archive in (created if missing)
KEEP Number of archives to retain (default: 7)
EOF
exit 64
}
(( $# >= 2 )) || usage
src="$1"
dst="$2"
keep="${3:-7}"
[[ -d "$src" ]] || { log ERROR "source '$src' not a directory"; exit 66; }
[[ "$keep" =~ ^[0-9]+$ ]] || { log ERROR "keep must be a positive integer"; exit 64; }
mkdir -p "$dst"
stamp="$(date '+%Y%m%dT%H%M%S')"
archive="${dst%/}/$(basename "$src")-${stamp}.tar.gz"
# Ensure we never leave a half-written archive behind
cleanup() {
local rc=$?
if (( rc != 0 )) && [[ -f "$archive" ]]; then
log WARN "removing partial archive: $archive"
rm -f "$archive"
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
log INFO "creating $archive"
tar --create --gzip --file "$archive" --directory "$(dirname "$src")" "$(basename "$src")"
log INFO "archive size: $(du -h "$archive" | cut -f1)"
# Rotate: keep the $keep newest, delete the rest
mapfile -t archives < <(
find "$dst" -maxdepth 1 -type f -name "$(basename "$src")-*.tar.gz" -printf '%T@ %p\n' \
| sort -nr \
| awk '{ $1=""; sub(/^ /,""); print }'
)
if (( ${#archives[@]} > keep )); then
for stale in "${archives[@]:keep}"; do
log INFO "pruning $stale"
rm -f -- "$stale"
done
fi
log INFO "done, ${#archives[@]} archives total (keep=$keep)"
A few things to notice in that script that we discussed above:
mapfile -t archives < <(...)populates an array from a command's stdout without spawning a subshell (so any variables it sets are visible afterwards).find -printf '%T@ %p\n'emits modification-time-with-fractional-seconds first so we can sort numerically. Thenawkstrips the time back off. This is portable to weird filenames in a way thatls -tisn't.rm -f -- "$stale"uses--so a filename starting with-isn't treated as an option.- Exit codes follow
sysexits.hconventions — 64 for usage, 66 for missing input. Predictable exits make wrapping the script in cron / systemd timers easier.
Further Reading
- Bash Reference Manual —
man bashon any Linux box; the canonical source for parameter expansion, redirection rules, and shell options. - Greg Wooledge's Bash Pitfalls — a curated list of every common bash mistake (mywiki.wooledge.org/BashPitfalls).
- POSIX shell spec — useful when you genuinely need portable scripts. If you need bashisms, mark the script
#!/usr/bin/env bashand own it. - shellcheck.net — paste any script in the browser, get instant lint output.
When a script grows past a few hundred lines and starts wanting structured data, error handling beyond exit codes, or genuine unit tests, it's a sign you've outgrown bash. Reach for Python, Go, or Rust at that point — but for everything below that threshold, well-written bash is fast to write, easy to read, and runs everywhere.