DS DevShelfHub Projects · AI tools
Cheatsheets / Bash / Shell
Cheatsheet · Languages

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.

124 items ◷ 9 min Pipes Tests Expansion

Start hereQuick start · 6 you’ll reach for daily

Strict modeset -euo pipefail
Quote vars"$var" / "${arr[@]}"
Test[[ -f file && $x -gt 0 ]]
Loop filesfor f in *.md; do …
Captureout=$(cmd)
Cleanuptrap rm -- "$tmp" EXIT

Targets · portabilityVersions

Targets: bash ≥ 5 (modern features) bash 3.2 (macOS default) POSIX sh (max portability) shellcheck (lint)

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=AdaAssignment. No spaces around =.
name="Ada Lovelace"Quote when value has spaces.
echo "$name" / "${name}"Reference. Always quote.
readonly PI=3.14Constant. Cannot be reassigned.
export PATH=…Make available to child processes.
unset nameRemove variable.
declare -i n=42Typed: integer. -a array, -A assoc, -r readonly.
local x=1 (inside functions)Function-scoped. Required to avoid leaks.
FOO=bar cmdOne-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]=xSet 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.txtRead 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 … fiBranches 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) … ;; *) … ;; esacPattern match. | = or; * = default.
for f in *.md; do … doneGlob-driven loop.
for i in {1..10}; do … doneBrace range.
for ((i=0; i<10; i++)); do … doneC-style.
while cmd; do … doneLoop while exit 0.
until cmd; do … doneInverse: while non-zero.
while IFS= read -r line; do …; done < fileRead 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 symlinkPath predicates.
-r / -w / -xReadable / writable / executable.
-sFile exists and is non-empty.
-z $s / -n $sString is empty / non-empty.
$a = $b / $a != $bString equality inside [[ ]].
$a == pat*Glob match inside [[ ]].
$a =~ ^[0-9]+$Regex match. Capture into ${BASH_REMATCH[i]}.
-eq / -ne / -lt / -le / -gt / -geInteger comparison.

Define · local · returnFunctions

greet() { echo "hi $1"; }Define. Or function greet { … }.
greet "Ada"Call. Positional args in $1, $2, …
local x=1Function-scoped variable. Almost always what you want.
return 0Exit code (0–255). Not a return value.
echo "$result"; returnIdiomatic: caller captures via x=$(fn).
declare -n ref=$1Nameref (Bash 4.3+) — pass by reference.
"$@"Forward all args verbatim. Quote always.
shift [n]Drop first N positional args.
getopts ":hf:o:" optShort-option parser. POSIX.

stdin · stdout · stderrPipes & redirection

cmd1 | cmd2Pipe stdout of cmd1 into stdin of cmd2.
cmd > fileRedirect stdout (overwrite).
cmd >> fileAppend.
cmd 2> err.logRedirect stderr.
cmd &> both.logBoth stdout + stderr.
cmd 2>&1 | grep …Merge stderr into stdout, then pipe.
cmd > out 2> errSplit into separate files.
cmd < fileRead stdin from a file.
cmd <<EOF … EOFHeredoc 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 fileSplit stream: file + stdout.
>/dev/null 2>&1Discard 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.
$RANDOMPseudo-random integer 0–32767.
$EPOCHSECONDS / $EPOCHREALTIMEUnix 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 lineRead a line. -r = no backslash escapes. Always include.
read -p "name: " -r namePrompt + read.
read -t 5 -r answerTimeout. Non-zero exit on timeout.
find . -type f -name '*.md' -print0Null-terminated — safe for spaces / newlines in filenames.
find … -exec cmd {} +Batch — one cmd invocation with many args. Faster than \;.
xargs -0 -n1 -P4 cmdRun cmd in parallel. -0 for null-sep.
command -v gitPreferred "Is git installed?" Replaces which.
mktemp / mktemp -dRace-safe temp file / dir.
cat <<-EOF … EOFHeredoc with tab indent stripped (-).
basename / dirname / realpathPath manipulation. Or ${path##*/} / ${path%/*}.

Signal handlingTraps & cleanup

trap 'cleanup' EXITRuns on script exit, success or failure.
trap 'echo interrupted; exit 130' INT TERMHandle Ctrl-C cleanly.
trap 'echo "err on line $LINENO"' ERRPer-error hook. Pairs with set -e.
trap - SIGNALReset to default handler.
trap '' SIGNALIgnore the signal.
kill -lList 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.

Go deeperSee also

Bash / Shell FAQ

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.