POSIX · GNU BASH 5 · ENGINEER EDITION

Bash Scripting
in a Nutshell

A dense engineer's reference covering script anatomy, variables, control flow, functions, arrays, process substitution, traps, error handling patterns, and the full builtin vocabulary.

#!/bin/bash
Shebang — interpreter declaration
$?
Exit code of last command
set -euo
Strict mode — fail fast
POSIX
IEEE 1003.1 shell standard
SECTION 01

Overview — Bash at a Glance

Bash (Bourne Again SHell) is the default shell on most Linux distros and macOS (≤10.14). It is simultaneously an interactive command interpreter and a full scripting language for automation, DevOps, and system administration.

WHAT BASH IS
Interpreted — no compile step; runs line-by-line in a subshell or interactive session
POSIX superset — portable core + Bash-specific extensions (arrays, [[ ]])
Process-centric — pipelines, subshells, coprocess, job control
String-native — all variables are strings; arithmetic via (( )) or expr
EXECUTION MODES
Interactive — reads commands from terminal, has readline, history, tab completion
Non-interactive — script file execution; bash script.sh or ./script.sh
Login shell — sources /etc/profile, ~/.bash_profile on start
Subshell(cmd) forks; variable changes don't affect parent
STRENGTHS
File & text manipulation  ·  Process orchestration  ·  Gluing CLI tools  ·  CI/CD pipelines  ·  System bootstrapping  ·  Cron automation
WHEN TO SWITCH LANGUAGES
Data structures → Python  ·  Performance loops → C/Rust  ·  JSON/YAML parsing → Python  ·  Concurrent tasks → Go  ·  Rule of thumb: >200 lines → reconsider Bash
VERSION CHECK
bash --version — target Bash 4.x+ for associative arrays, 5.x+ for nameref. macOS ships Bash 3.2 (GPL2); install 5.x via Homebrew.
SECTION 02

Script Anatomy & Execution Model

How Bash parses, expands, and executes every line — and the layered structure of a production-grade script.

Shebang
#!/usr/bin/env bash Prefer /usr/bin/env bash over /bin/bash for portability across distros. Must be first line, no spaces.
Strict Mode
set -e set -u set -o pipefail set -x Canonical: set -euo pipefail — always use in production scripts
Expansions
Variable Command Sub Arithmetic Glob / Brace Word Split Order: brace → tilde → param → cmd → arith → word-split → glob
Quoting
'single' "double" \backslash $'c-esc' 💡 Always double-quote "$VAR" to prevent word splitting and globbing
Redirections
> file >> file 2> err 2>&1 &> file <<EOF <(cmd) /dev/null
Exit Codes
0 = success 1 = general error 2 = misuse of shell 126 = not executable 127 = cmd not found 128+N = killed by signal N · $? holds last exit code · exit N to return from script
PRODUCTION SCRIPT SKELETON
#!/usr/bin/env bash
set -euo pipefail
# IFS=$'\n\t' # safer word splitting for files-with-spaces
 
### ── Constants ────────────────────────────────────────
readonly SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
readonly LOG_FILE="/var/log/$(basename "$0" .sh).log"
 
### ── Helpers ──────────────────────────────────────────
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG_FILE" >&2; }
die() { log "FATAL: $*"; exit 1; }
need() { command -v "$1" >/dev/null || die "Required: $1"; }
 
### ── Cleanup on exit ─────────────────────────────────
cleanup() { rm -f "${TMPFILE:-}"; }
trap cleanup EXIT INT TERM
TMPFILE=$(mktemp)
 
### ── Usage ───────────────────────────────────────────
usage() { echo "Usage: $0 [-v] <target>"; exit 0; }
while getopts "vh" opt; do
case "$opt" in
v) VERBOSE=1 ;; h) usage ;;
*) usage ;;
esac
done
shift "$(( OPTIND - 1 ))"
[[ $# -lt 1 ]] && usage
 
### ── Main ────────────────────────────────────────────
main() {
need curl; need jq
log "Processing target: $1"
# ... work here ...
}
main "$@"
SECTION 03

Core Syntax — Control Flow & Functions

Conditionals, loops, case, and function definitions — the structural vocabulary of every Bash script.

CONDITIONALS — [[ ]] vs [ ] vs (( ))
# [[ ]] — Bash extended test (preferred)
if [[ -f "$FILE" && -r "$FILE" ]]; then
echo "readable file"
elif [[ $COUNT -gt 10 ]]; then
echo "count exceeded"
else
echo "default"
fi
 
# (( )) — arithmetic condition (no $)
if (( COUNT > 0 && COUNT < 100 )); then
echo "in range"
fi
 
# one-liner guard pattern
[[ -d "$DIR" ]] || die "$DIR not found"
LOOPS — for / while / until / select
# C-style arithmetic for
for (( i=0; i<10; i++ )); do
printf "%d\n" "$i"
done
 
# glob / array for
for file in /var/log/*.log; do
gzip -9 "$file"
done
 
# while read line-by-line
while IFS= read -r line; do
echo "> $line"
done < "$FILE"
 
# break / continue / loop labels
break 2 # break outer of 2 nested loops
continue # skip to next iteration
CASE STATEMENT & PATTERN MATCHING
case "$1" in
start|run)
start_service && log "started" ;;
stop)
stop_service ;;
--dry-run*) # glob matches --dry-run-XXX
DRY_RUN=1 ;;
*.sh)
source "$1" ;;
*)
die "Unknown: $1" ;;
esac
 
# ;; terminate, ;& fall-through, ;;& re-test
FUNCTIONS — DEFINITION & SCOPE
# preferred syntax (no 'function' keyword)
greet() {
local name="${1:?name required}"
local -i count=0 # -i = integer attr
echo "Hello, $name"
return 0
}
 
# return value via stdout capture
get_ts() { date '+%s'; }
TS=$(get_ts)
 
# pass array by name (nameref, bash 4.3+)
sum_arr() {
local -n arr="$1" # nameref
local total=0
for v in "${arr[@]}"; do (( total += v )); done
echo "$total"
}
SECTION 04

Variables, Arrays & Parameter Expansion

Bash's data model — scalar variables, indexed arrays, associative arrays, and the powerful parameter expansion mini-language.

Scalars
VAR=value no spaces local readonly declare -i export · unset VAR removes · ${VAR:-default} safe access
Indexed Array
arr=(a b c) ${arr[0]} "${arr[@]}" ${!arr[@]} arr+=(d e) ${#arr[@]} ${arr[@]:1:2}
Assoc Array
declare -A map[key]=val "${map[key]}" ${!map[@]} ${map[@]} · iterate: for key in "${!map[@]}"
Special Vars
$? $$ $! $# "$@" "$*" $0 $FUNCNAME $LINENO
PARAMETER EXPANSION QUICK REFERENCE
${VAR:-default} Use default if VAR unset or empty
${VAR:=default} Assign default if unset, then expand
${VAR:?message} Error + exit if unset or empty
${#VAR} Length of string value
${VAR:2:5} Substring from offset 2, length 5
${VAR#pattern} Strip shortest prefix match
${VAR##pattern} Strip longest prefix match
${VAR%pattern} Strip shortest suffix match
${VAR/old/new} Replace first match
${VAR//old/new} Replace all matches
${VAR^^} UPPER case entire string
${VAR,,} lower case entire string
${VAR^} Capitalise first char
SECTION 05

Patterns, Traps & Error Handling

Production patterns — trap-based cleanup, parallel jobs, here-docs, getopts parsing, and the ten most impactful idioms.

PATTERN / IDIOM SYNTAX / EXAMPLE WHY IT MATTERS
Trap cleanup trap 'rm -f "$TMP"' EXIT Temp files cleaned even on error or Ctrl-C
mktemp safe tempfile TMP=$(mktemp) Avoids race conditions vs /tmp/fixed-name
Parallel jobs + wait cmd1 & cmd2 & wait Fan-out tasks; wait blocks until all complete
Bounded parallelism xargs -P 4 -I{} cmd {} Max 4 concurrent processes at any time
Command existence check command -v jq >/dev/null Portable; prefer over which or type
Printf over echo printf '%s\n' "$VAR" echo -e behaviour varies by shell; printf portable
Read into array mapfile -t arr < file.txt Handles spaces/newlines in filenames safely
Process substitution diff <(cmd1) <(cmd2) Avoids temp files for command-to-command diff
Here-string grep 'x' <<<"$VAR" Feed a variable as stdin without echo pipe
Subshell isolation ( cd /tmp; rm -f *.tmp ) cd in subshell doesn't change parent's CWD
TRAP SIGNAL REFERENCE
EXIT always cleanup
ERR on any error
INT keyboard interrupt
TERM graceful terminate
HUP reload config
DEBUG before each cmd
RETURN function exit
PIPE broken pipe
SECTION 06

Scripting Workflows

Structured step-by-step guides for the two most common real-world Bash scripting tasks.

ROBUST LOG PROCESSING PIPELINE
1
Validate inputs — check file exists, is readable, has expected header. Use [[ -f "$F" && -r "$F" ]] and wc -l sanity check. Exit early with clear message on failure.
2
Stream-process with pipelinegrep | awk | sort | uniq -c | sort -rn | head. Never load entire file into memory. Use while IFS= read -r line for line-by-line logic.
3
Write atomically — write to $(mktemp), then mv to final destination. mv is atomic on same filesystem — readers never see partial output.
4
Emit structured output — print CSV or JSON with printf '%s,%s\n' "$k" "$v". Log progress to stderr (>&2); keep stdout clean for piping downstream.
DEPLOYMENT / ROLLBACK SCRIPT
1
Pre-flight checks — verify required tools (command -v), environment vars set, disk space (df -k), and connectivity (nc -zw2 host port) before any changes.
2
Snapshot for rollbackcp -a /opt/app /opt/app.bak.$(date +%s). Store current git SHA: PREV=$(git rev-parse HEAD). Register trap rollback ERR immediately after.
3
Deploy with health check — apply changes, restart service, then poll curl -sf http://localhost/health in a retry loop (max N attempts with sleep backoff). Fail if health never passes.
4
Cleanup or rollback — on success: remove old backup, trap - EXIT to disarm. On failure (ERR trap fires): git checkout "$PREV", restore backup, restart, alert. Always exit non-zero on rollback.
SECTION 07

Builtins & Special Commands

Commands that run inside the shell process — no fork overhead. Know these to avoid unnecessary subprocess spawns.

CORE BUILTINS
declare Type-annotate variables; inspect all vars with no args
read Read stdin into variable(s); -r raw, -t timeout, -s silent (passwords)
printf Formatted output — C-printf format strings; no newline by default
source / . Execute script in current shell context — env changes persist
exec Replace shell with command (no fork); also redirects FDs
eval Execute string as Bash code — avoid; use arrays or declare instead
JOB CONTROL & SHELL STATE
getopts Parse short flags from $@; colon after letter = requires argument
shift Discard N leading positional params; $1 becomes former $N+1
wait Block until background job(s) finish; captures their exit codes
mapfile Read lines from stdin/file into indexed array; preserves whitespace
type Show what a name resolves to — builtin, function, alias, or external
enable Enable/disable individual builtins — advanced shell configuration
STRING & ARITHMETIC TOOLS
(( )) Arithmetic — no $ on vars; C operators ++ -- ** % & | ^ ~ <<
$(( )) Arithmetic substitution — result as string; use for assignments
[[ =~ ]] ERE regex match — captures in BASH_REMATCH array
$SECONDS Builtin timer — integer seconds since shell started; reset to 0 to benchmark
$RANDOM Pseudo-random 0–32767; $(( RANDOM % N )) for range
COMMON PITFALLS
Unquoted $VAR Word splits on spaces → wrong args. Always use "$VAR"
Parsing ls Never parse ls — use glob for f in /path/* or find instead
cd without check cd /nonexistent && rm -rf * = disaster. Use cd dir || die
[ vs [[ regex Never quote the right side of =~: [[ $x =~ ^[0-9]+$ ]] not "^[0-9]+$"
Subshell pipe cmd | while read runs in subshell — vars set inside won't persist. Use <(cmd) or lastpipe option
SECTION 08

Values Cheatsheet

Six copy-ready reference cards — test operators, arithmetic, string ops, getopts template, parallel pattern, and debugging toolkit.

[[ ]] TEST OPERATORS
FILE TESTS
-fregular file exists
-ddirectory exists
-r -w -xreadable / writable / executable
-sfile non-empty (size > 0)
-Lis a symlink
STRING
-zempty string
-nnon-empty string
= == !=string comparison
INTEGER
-eq -neequal / not equal
-lt -gtless / greater than
-le -ge≤ / ≥
ARITHMETIC OPERATIONS
# integer arithmetic — no spaces issue
(( a = 5 * 3 - 1 )) # a=14
(( a++ )) (( a-- )) # increment
(( a ** 2 )) # exponentiation
(( a % 3 )) # modulo
(( a & 0xFF )) # bitwise AND
(( a | b )) (( a ^ b )) # OR XOR
(( a << 2 )) # left shift ×4
 
# float via bc/awk (no Bash native)
RESULT=$(echo "scale=2; 22/7" | bc)
RESULT=$(awk "BEGIN{printf '%.4f',22/7}")
STRING OPERATIONS QUICK-REF
S="Hello World"
echo "${#S}" # 11 — length
echo "${S:6}" # World
echo "${S:0:5}" # Hello
echo "${S/ /,}" # Hello,World
echo "${S,,}" # hello world
echo "${S^^}" # HELLO WORLD
echo "${S#Hello }" # World (strip prefix)
echo "${S%.World}" # (strip suffix)
 
# regex match
[[ $S =~ ^Hello ]] && echo "match"
echo "${BASH_REMATCH[0]}" # full match
GETOPTS OPTION PARSING
VERBOSE=0; OUTFILE=""
 
usage() {
printf 'Usage: %s [-v] [-o out] <file>\n' "$0"
exit 0
}
 
while getopts ":vo:h" opt; do
case "$opt" in
v) VERBOSE=1 ;;
o) OUTFILE="$OPTARG" ;;
h) usage ;;
:) die "-$OPTARG needs arg" ;;
\?) die "Unknown: -$OPTARG" ;;
esac
done
shift "$(( OPTIND - 1 ))"
PARALLEL JOBS PATTERN
# run jobs in background, collect PIDs
declare -A PIDS
for host in "${HOSTS[@]}"; do
ping -c1 -W2 "$host" >/dev/null &
PIDS["$host"]=$!
done
 
# collect results
for host in "${!PIDS[@]}"; do
if wait "${PIDS[$host]}"; then
echo "$host UP"
else
echo "$host DOWN"
fi
done
 
# bounded pool via xargs -P
printf '%s\n' "${HOSTS[@]}" |
xargs -P4 -I{} bash -c 'ping -c1 {}'
DEBUGGING TOOLKIT
# run script in debug mode
bash -x script.sh # trace all cmds
bash -n script.sh # syntax check only
bash -v script.sh # print as read
 
# toggle debug inside script
set -x # on — prefixes cmds with +
set +x # off — silence again
 
# caller shows call stack in functions
debug_fn() { echo "Called from: $(caller 0)"; }
 
# shellcheck — static analysis
shellcheck script.sh
# SC2086 unquoted, SC2064 trap quoting
 
# benchmark a section
SECONDS=0; heavy_fn; echo "${SECONDS}s"