Bash Scripting Cheat Sheet

Quoting, expansion, strict mode, traps and the constructs that silently corrupt data — the reference for writing shell that survives production.

Shell Scripting command reference

Strict mode

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
FlagEffect
-eExit on any command returning non-zero
-uExit on use of an unset variable
-o pipefailA pipeline fails if any stage fails, not just the last
-EERR 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
FormBehaviour
"$var"One word, expansions happen
'$var'Literal, no expansion
"$@"Each argument as its own word — almost always correct
"$*"All arguments joined into one word
$@ unquotedSplits 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

CodeMeaning
0Success
1General error
2Misuse of a builtin / bad arguments
126Found but not executable
127Command not found
128+NKilled 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.