Bash Scripting Cheat Sheet
Quoting, expansion, strict mode, traps and the constructs that silently corrupt data — the reference for writing shell that survives production.
Strict mode
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
| Flag | Effect |
|---|---|
-e | Exit on any command returning non-zero |
-u | Exit on use of an unset variable |
-o pipefail | A pipeline fails if any stage fails, not just the last |
-E | ERR traps fire inside functions and subshells |
Without pipefail, false \| true succeeds — which is how broken pipelines pass
CI for months.
Where set -e stops protecting you
It does not exit when a command fails in any of these positions:
if failing_cmd; then ...; fi # condition context
failing_cmd || true # left of || or &&
failing_cmd && other # left of &&
! failing_cmd # negated
failing_cmd | grep x # any stage but the last, without pipefail
Assignment hides exit status too:
local out=$(failing_cmd) # exit status is local's, not the command's
local out # declare and assign separately
out=$(failing_cmd) # now -e sees the real status
Quoting
Quote every expansion. Unquoted variables undergo word splitting and glob expansion — the single largest source of shell bugs.
file="my report.txt"
rm $file # deletes "my" and "report.txt"
rm "$file" # correct
| Form | Behaviour |
|---|---|
"$var" | One word, expansions happen |
'$var' | Literal, no expansion |
"$@" | Each argument as its own word — almost always correct |
"$*" | All arguments joined into one word |
$@ unquoted | Splits on whitespace, destroys arguments with spaces |
# Correct argument forwarding
main() { some_cmd "$@"; }
Parameter expansion
${var:-default} # use default if unset or empty
${var:=default} # assign default if unset
${var:?message} # exit with message if unset — good for required config
${var:+alt} # use alt only if var IS set
${#var} # length
${var#prefix} # strip shortest leading match
${var##*/} # strip longest — basename
${var%suffix} # strip shortest trailing match
${var%.*} # strip extension
${var/old/new} # replace first
${var//old/new} # replace all
${var^^} # uppercase
${var,,} # lowercase
${var:?} is the cheapest way to fail fast on missing configuration:
: "${DATABASE_URL:?DATABASE_URL must be set}"
Tests
[[ -f file ]] # regular file exists
[[ -d dir ]] # directory exists
[[ -s file ]] # exists and is non-empty
[[ -r file ]] # readable
[[ -z "$s" ]] # empty string
[[ -n "$s" ]] # non-empty
[[ "$a" == "$b" ]] # string equality
[[ "$a" == pat* ]] # glob match — do not quote the pattern
[[ "$a" =~ ^[0-9]+$ ]] # regex — do not quote the regex
(( a > b )) # arithmetic
Prefer [[ ]] over [ ]: no word splitting inside, supports &&, ||, =~.
Loops that do not corrupt data
Reading a file with a bare for splits on whitespace and expands globs:
# WRONG — breaks on spaces
for line in $(cat file); do ...; done
# CORRECT
while IFS= read -r line; do
printf '%s\n' "$line"
done < file
IFS= preserves leading and trailing whitespace; -r stops backslash mangling.
A pipeline into while runs in a subshell, so variables set inside are lost:
count=0
cat file | while read -r l; do ((count++)); done
echo "$count" # still 0
# Use redirection or process substitution instead
while read -r l; do ((count++)); done < file
while read -r l; do ((count++)); done < <(some_cmd)
For filenames, use null separation — the only character illegal in a filename:
find . -name '*.log' -print0 | while IFS= read -r -d '' f; do
printf 'processing %s\n' "$f"
done
Arrays
arr=(one two "three four")
echo "${arr[0]}" # first element
echo "${arr[@]}" # all, each as its own word
echo "${#arr[@]}" # count
arr+=("five") # append
for x in "${arr[@]}"; do echo "$x"; done # quotes are mandatory
declare -A map # associative array
map[key]="value"
for k in "${!map[@]}"; do echo "$k=${map[$k]}"; done
Traps and cleanup
tmp=$(mktemp -d)
cleanup() { rm -rf "$tmp"; }
trap cleanup EXIT # runs on any exit, including errors
trap 'echo "failed at line $LINENO" >&2' ERR
trap 'echo interrupted >&2; exit 130' INT TERM
EXIT fires on normal exit, set -e exit, and explicit exit — making it the
only reliable place to clean up temporary files.
Locking for cron
Two overlapping runs of the same job is a classic production failure:
exec 200>/var/lock/myjob.lock
flock -n 200 || { echo "already running" >&2; exit 1; }
# work here; the lock releases when the fd closes at exit
Exit codes
| Code | Meaning |
|---|---|
0 | Success |
1 | General error |
2 | Misuse of a builtin / bad arguments |
126 | Found but not executable |
127 | Command not found |
128+N | Killed by signal N (130 = Ctrl-C, 137 = SIGKILL) |
cmd
status=$? # capture immediately — any command overwrites it
Debugging
bash -n script.sh # syntax check, no execution
bash -x script.sh # trace every command
set -x; ...; set +x # trace one section
# Trace with file and line numbers
export PS4='+ ${BASH_SOURCE}:${LINENO}: '
Run ShellCheck on everything. It catches the quoting bugs above before they reach production.