Bash Shell Scripting Cheatsheet: Variables, Pipes and More
By DevShelfHub
Variables, parameter expansion, arrays, control flow, functions, tests, pipes/redirection, process substitution, traps, set -euo pipefail — the daily shell-scripting surface.
macOS ships with Bash 3.2 (licensing reasons). Install Bash 5 via Homebrew and put it in the shebang
(#!/usr/bin/env bash) when you need associative arrays, mapfile, or
${var^^}. For maximum portability, target POSIX sh — avoid arrays,
[[ ]], <( ), and ((...)).
Run shellcheck on every script — it catches the bugs this cheatsheet warns about.
Strict mode · trapsSafe scripting
bash
#!/usr/bin/env bash
#
# Always start scripts with safe defaults — the "unofficial strict mode"
set -euo pipefail # -e fail on error, -u no unset vars, -o pipefail propagate pipe errors
IFS=$'\n\t' # safer word splitting (no spaces)
# Cleanup runs even on failure
tmpdir=$(mktemp -d)
cleanup() { rm -rf "$tmpdir"; }
trap cleanup EXIT
trap 'echo "interrupted"; exit 130' INT TERM
# Require an argument
if (( $# < 1 )); then
echo "usage: $0 " >&2
exit 64 # EX_USAGE
fi
name=$1
echo "working in $tmpdir for $name"
# Capture stderr separately
if ! out=$(command_that_might_fail 2> >(tee /tmp/err.log >&2)); then
echo "failed; see /tmp/err.log" >&2
exit 1
fi
echo "done: $out"
Assignment · scopeVariables
name=Ada
Assignment. No spaces around =.
name="Ada Lovelace"
Quote when value has spaces.
echo "$name" / "${name}"
Reference. Always quote.
readonly PI=3.14
Constant. Cannot be reassigned.
export PATH=…
Make available to child processes.
unset name
Remove variable.
declare -i n=42
Typed: integer. -a array, -A assoc, -r readonly.
local x=1 (inside functions)
Function-scoped. Required to avoid leaks.
FOO=bar cmd
One-call env override. Doesn’t persist.
$$
Current PID.
$? / $! / $0 / $#
Last exit code / last background PID / script name / arg count.
$@ vs $*
All args. Quote: "$@" preserves each arg; "$*" joins as one string.
${var…} tricksParameter expansion
${var:-default}
Default when unset or empty. Doesn’t assign.
${var:=default}
Default + assign to var.
${var:?error}
Error + exit if unset.
${var:+alt}
Use alt only if var is set.
${#var}
Length.
${var:offset:length}
Substring.
${var#prefix} / ${var##prefix}
Strip shortest / longest matching prefix.
${var%suffix} / ${var%%suffix}
Strip shortest / longest matching suffix.
${var/pat/repl}
First match replace.
${var//pat/repl}
All matches.
${var^^} / ${var,,}
UPPER / lower (Bash 4+).
${!ref}
Indirect — expand variable whose name is in ref.
${!prefix*}
List vars starting with prefix.
Worked example
bash
#!/usr/bin/env bash
set -euo pipefail
# ── Parameter expansion — Bash's swiss army knife
path="/home/abhi/notes/draft.md"
echo "${path}" # /home/abhi/notes/draft.md
echo "${path##*/}" # draft.md (strip longest prefix matching */)
echo "${path%/*}" # /home/abhi/notes (strip shortest suffix matching /*)
echo "${path%.*}.bak" # /home/abhi/notes/draft.bak
echo "${path//notes/papers}" # /home/abhi/papers/draft.md (all replacements)
echo "${path/notes/papers}" # first replacement only
echo "${path:6:4}" # substring: start 6, length 4
# ── Defaults & required
echo "${NAME:-anon}" # default if unset or empty
echo "${NAME:=anon}" # assign default if unset
: "${REQUIRED:?must be set}" # error + exit if unset
# ── Length
echo "${#path}" # 28
# ── Indirect ref
key=path
echo "${!key}" # value of $path
# ── Case
echo "${path^^}" # uppercase
echo "${path,,}" # lowercase
Indexed · associativeArrays
arr=(a b c "with spaces")
Indexed array literal.
arr[3]=x
Set by index.
arr+=(d e)
Append.
${arr[0]}
Single element.
"${arr[@]}"
All elements, separately. Quote it.
${#arr[@]}
Length.
${arr[@]:1:2}
Slice: 2 elements starting at index 1.
unset 'arr[2]'
Delete element. Quotes prevent globbing.
mapfile -t lines < file.txt
Read file into array. -t strips trailing newlines.
readarray -t lines < <(cmd)
Same, from a command. Avoids subshell.
declare -A map=([a]=1 [b]=2)
Associative array (Bash 4+).
"${!map[@]}"
Keys.
[[ -v map[a] ]]
Test key existence.
Worked example
bash
#!/usr/bin/env bash
set -euo pipefail
# Indexed arrays
fruits=(apple banana cherry "dragon fruit")
echo "${fruits[0]}" # apple
echo "${fruits[@]}" # all elements
echo "${#fruits[@]}" # length
echo "${fruits[@]:1:2}" # slice: banana cherry
fruits+=(elderberry) # append
# Iterate — quote @ to preserve spaces in elements
for f in "${fruits[@]}"; do
echo "- $f"
done
# Read lines into an array
mapfile -t lines < input.txt # one line per element, no trailing \n
# alt: readarray -t lines < input.txt (same thing)
# Associative arrays (Bash 4+) — declare with -A
declare -A ages=([ada]=36 [grace]=85 [linus]=54)
ages[turing]=41 # add a key
echo "${ages[ada]}" # 36
for name in "${!ages[@]}"; do # keys
echo "$name → ${ages[$name]}"
done
# Test membership
[[ -v ages[ada] ]] && echo "found ada"
if · case · loopsControl flow
if cmd; then … elif cmd; then … else … fi
Branches on exit code (0 = true).
[[ $x -gt 0 && -f $f ]]
Preferred Modern conditional. Don’t expand globs / split words.
[ $x -gt 0 ]
Legacy POSIX test. Quoting matters more.
(( x > 0 && y < 10 ))
Arithmetic comparison. No quoting needed.
case $x in pat1) … ;; pat2|pat3) … ;; *) … ;; esac
Pattern match. | = or; * = default.
for f in *.md; do … done
Glob-driven loop.
for i in {1..10}; do … done
Brace range.
for ((i=0; i<10; i++)); do … done
C-style.
while cmd; do … done
Loop while exit 0.
until cmd; do … done
Inverse: while non-zero.
while IFS= read -r line; do …; done < file
Read a file line by line, preserving spaces.
break / continue [n]
Exit / skip nested loops, optional depth.
Test operators
-f file / -d dir / -e exists / -L symlink
Path predicates.
-r / -w / -x
Readable / writable / executable.
-s
File exists and is non-empty.
-z $s / -n $s
String is empty / non-empty.
$a = $b / $a != $b
String equality inside [[ ]].
$a == pat*
Glob match inside [[ ]].
$a =~ ^[0-9]+$
Regex match. Capture into ${BASH_REMATCH[i]}.
-eq / -ne / -lt / -le / -gt / -ge
Integer comparison.
Define · local · returnFunctions
greet() { echo "hi $1"; }
Define. Or function greet { … }.
greet "Ada"
Call. Positional args in $1, $2, …
local x=1
Function-scoped variable. Almost always what you want.
return 0
Exit code (0–255). Not a return value.
echo "$result"; return
Idiomatic: caller captures via x=$(fn).
declare -n ref=$1
Nameref (Bash 4.3+) — pass by reference.
"$@"
Forward all args verbatim. Quote always.
shift [n]
Drop first N positional args.
getopts ":hf:o:" opt
Short-option parser. POSIX.
stdin · stdout · stderrPipes & redirection
cmd1 | cmd2
Pipe stdout of cmd1 into stdin of cmd2.
cmd > file
Redirect stdout (overwrite).
cmd >> file
Append.
cmd 2> err.log
Redirect stderr.
cmd &> both.log
Both stdout + stderr.
cmd 2>&1 | grep …
Merge stderr into stdout, then pipe.
cmd > out 2> err
Split into separate files.
cmd < file
Read stdin from a file.
cmd <<EOF … EOF
Heredoc to stdin.
cmd <<< "$string"
Herestring — pipe a single string.
cmd <(cmd2)
Process substitution — pass output as a filename.
cmd >(cmd2)
Reverse PS — pipe output into a command.
tee file
Split stream: file + stdout.
>/dev/null 2>&1
Discard everything.
Capture output · mathSubstitution & arithmetic
out=$(cmd)
Preferred Command substitution.
out=`cmd`
Legacy Backticks. Don’t nest.
$(( a + b ))
Arithmetic expansion. Returns the value.
(( i++ ))
Arithmetic statement. Returns an exit code — 0 if result is non-zero.
(( c = a > b ? a : b ))
Ternary in arithmetic context.
$RANDOM
Pseudo-random integer 0–32767.
$EPOCHSECONDS / $EPOCHREALTIME
Unix time / floating point (Bash 5+).
printf '%d %s\n' 42 "ok"
printf works like C. Prefer over echo -e.
echo -n "no newline"
Suppress trailing newline.
read · find · xargsI/O helpers
read -r line
Read a line. -r = no backslash escapes. Always include.
read -p "name: " -r name
Prompt + read.
read -t 5 -r answer
Timeout. Non-zero exit on timeout.
find . -type f -name '*.md' -print0
Null-terminated — safe for spaces / newlines in filenames.
find … -exec cmd {} +
Batch — one cmd invocation with many args. Faster than \;.
xargs -0 -n1 -P4 cmd
Run cmd in parallel. -0 for null-sep.
command -v git
Preferred "Is git installed?" Replaces which.
mktemp / mktemp -d
Race-safe temp file / dir.
cat <<-EOF … EOF
Heredoc with tab indent stripped (-).
basename / dirname / realpath
Path manipulation. Or ${path##*/} / ${path%/*}.
Signal handlingTraps & cleanup
trap 'cleanup' EXIT
Runs on script exit, success or failure.
trap 'echo interrupted; exit 130' INT TERM
Handle Ctrl-C cleanly.
trap 'echo "err on line $LINENO"' ERR
Per-error hook. Pairs with set -e.
trap - SIGNAL
Reset to default handler.
trap '' SIGNAL
Ignore the signal.
kill -l
List signal names.
Full script · ~45 linesEnd-to-end · Backup script
Strict mode, getopts CLI parsing, null-safe find, retry loop, timestamped tarball, EXIT trap cleanup — the structure every real Bash script ends up with.
bash
#!/usr/bin/env bash
#
# Backup all *.md files in a directory to a tar.gz with timestamped name.
# Logs progress, retries on transient failure, cleans up on exit.
set -euo pipefail
IFS=$'\n\t'
usage() {
cat <<-EOF
usage: $(basename "$0") [-v]
-v verbose (print each file)
EOF
exit 64
}
verbose=0
while getopts ":v" opt; do
case "$opt" in
v) verbose=1 ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
(( $# == 2 )) || usage
src=$1
dest=$2
[[ -d $src ]] || { echo "no such dir: $src" >&2; exit 66; }
mkdir -p "$dest"
timestamp=$(date +%Y%m%d-%H%M%S)
archive="$dest/notes-$timestamp.tar.gz"
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# Collect markdown files null-safely
mapfile -d '' -t files < <(find "$src" -type f -name '*.md' -print0)
(( ${#files[@]} > 0 )) || { echo "no markdown files in $src" >&2; exit 0; }
(( verbose )) && printf 'including: %s\n' "${files[@]}"
# Retry the tar up to 3 times
for try in 1 2 3; do
if tar -czf "$archive" -C "$src" "${files[@]##$src/}"; then
echo "wrote $archive ($(du -h "$archive" | cut -f1))"
exit 0
fi
echo "attempt $try failed, retrying..." >&2
sleep $((try * 2))
done
echo "failed after 3 attempts" >&2
exit 1
Best practiceGood to know
Start every script with set -euo pipefail.
Without it, failed commands inside a pipeline silently succeed, undefined vars expand to empty, and a typo can rm -rf / something important.
Run shellcheck on every script.
It catches missing quotes, mis-use of [ ], subshell-loses-variable issues, and far more. Treat its output like a typechecker, not advice.
Quote variable expansions almost always."$var" not $var. The unquoted form word-splits and globs — the source of the classic "works on my machine, breaks on production filenames with spaces" bug.
Common trapsWatch out for
Pipes spawn subshells — variables don’t escape.cat f | while read l; do count=$((count+1)); done leaves count unchanged. Use process substitution (while … done < <(cmd)) instead.
set -e doesn’t trigger on every failure.
Commands inside if, &&, or piped (without pipefail) all bypass it. local x=$(failingcmd) also masks failure — declare and assign separately.
Filenames can contain anything except / and NUL.
Newlines, leading dashes, glob characters — all legal. Use find -print0 + read -d '' when iterating, and quote ferociously.
What does `set -euo pipefail` do in a Bash script?
`set -e` exits the script on any non-zero return code. `set -u` treats unset variables as errors instead of silently expanding them to an empty string. `set -o pipefail` fails a pipeline if any command in it fails, not just the last one. Together they catch the majority of silent errors in shell scripts.
How do Bash arrays work?
Declare an indexed array with `arr=(a b c)` and access elements with `${arr[0]}`. Use `${arr[@]}` to expand all elements and `${#arr[@]}` for the length. Associative (key-value) arrays require `declare -A map` and then `map[key]=value` assignments.
What is parameter expansion in Bash?
Parameter expansion lets you transform variable values inline. Common forms include `${var:-default}` (use default if unset), `${var%suffix}` (strip shortest suffix), `${var##prefix}` (strip longest prefix), and `${var/old/new}` (replace first match). They avoid a subprocess call for simple string manipulation.
How do I pass arguments to a Bash function?
Bash functions receive positional parameters just like scripts: `$1`, `$2`, etc., and `$@` for all arguments. Define a function with `my_func() { echo 'first: $1'; }` and call it with `my_func arg1 arg2`. There is no named-parameter syntax — use local variables to give them readable names inside the function body.
How do I trap errors and clean up in a Bash script?
Use `trap 'cleanup_fn' EXIT` to run a function whenever the script exits, regardless of reason. `trap 'echo error on line $LINENO' ERR` fires on every non-zero exit. Combining both with `set -e` ensures cleanup always runs and the error location is logged, making debugging significantly easier.