Bash Scripting Fundamentals

Bash is the lingua franca of Unix automation. Every Linux server you'll ever touch has it, and most administrative tooling — installers, CI scripts, container entrypoints — is written in it. The catch: Bash is permissive by default. It happily runs scripts that silently corrupt files because a single variable was empty, or because a pipeline failed halfway through and nobody noticed. This guide is about writing Bash that fails loudly and predictably.

We'll work through the building blocks in order, finishing with a small but real backup-rotation script you can adapt. The examples target Bash 4+ (because we'll use associative arrays and a few modern features); on macOS this means installing GNU bash via Homebrew or running on a Linux host.

Shebang and Strict Mode

Every script starts with a shebang: the magic #! line that tells the kernel which interpreter to use. Prefer /usr/bin/env so the script finds whatever bash is first on the user's PATH — important on macOS and BSDs where the system bash is ancient.

Script header we use every time

#!/usr/bin/env bash
#
# backup-rotate.sh — keep N most recent backups of a directory
# Author: alex
# Requires: bash 4+, GNU coreutils

set -Eeuo pipefail
IFS=$'\n\t'

That set line is "strict mode". Each flag earns its keep:

  • -e — Exit immediately if any command returns non-zero. Stops the "we kept going after the error" bug class.
  • -E — Make ERR traps inherit into functions and subshells. Pair with trap for useful error messages.
  • -u — Treat unset variables as an error. Catches typos like $PHATH.
  • -o pipefail — A pipeline's exit code is the rightmost non-zero exit. Without this, false | true exits 0 and you never know the left side failed.

The IFS=$'\n\t' line replaces the default word-splitting characters (space, tab, newline) with just newline and tab. The practical effect: filenames with spaces stop turning into multiple arguments when unquoted. It is not a substitute for proper quoting, but it removes a footgun.

Caveat: Strict mode interacts with arithmetic and certain conditionals. let x=0 and (( x=0 )) return non-zero when the result is zero — they will trip set -e. Use (( x = 0 )) || true or just x=0 when the value happens to be zero.

Variables and Quoting

Bash variables are just strings. The only thing standing between your script and word-splitting chaos is double quotes. The rule is simple: always double-quote variable expansions. Single quotes prevent any expansion at all; backticks are deprecated in favour of $().

Quoting in practice

name="Alex Smith"
file="my photos/holiday.jpg"

echo "Hello, $name"            # → Hello, Alex Smith
echo 'Hello, $name'            # → Hello, $name (single quotes are literal)

# WRONG — splits on spaces, tries to stat two files
ls -l $file

# RIGHT — quoted, treated as one argument
ls -l "$file"

# Default values
greeting="${greeting:-hi}"     # use 'hi' if greeting is unset or empty
required="${1:?missing argument}"  # error out if $1 is unset/empty

# Length and substring
echo "${#name}"                # 10
echo "${name:0:4}"             # Alex

# Lower / upper case (bash 4+)
echo "${name,,}"               # alex smith
echo "${name^^}"               # ALEX SMITH

When in doubt, quote. The shellcheck linter (covered later) will scream at you for unquoted expansions — listen to it.

Positional Arguments

Scripts receive their arguments as positional parameters. The classics:

  • $0 — the script name as invoked
  • $1, $2, … — the first, second, … argument
  • $# — number of arguments
  • "$@" — all arguments, properly quoted (each one a separate word)
  • "$*" — all arguments joined by IFS into a single word (rarely what you want)
  • $? — exit status of the last command
  • $$ — current shell PID

Argument handling boilerplate

#!/usr/bin/env bash
set -Eeuo pipefail

usage() {
    cat <<EOF
Usage: $(basename "$0") <source> <destination> [count]
Copy <source> to <destination>, keeping at most [count] copies.
EOF
    exit 64    # EX_USAGE from /usr/include/sysexits.h
}

(( $# >= 2 )) || usage

src="$1"
dst="$2"
count="${3:-5}"          # default to 5 if not provided

echo "Backing up $src to $dst, keeping $count copies."

For non-trivial flag parsing, reach for getopts (POSIX) or shift through the positional list manually. getopts only handles single-letter flags; for GNU-style long options use getopt from util-linux or just hand-parse — flag libraries written in pure bash exist but tend to be more trouble than they're worth.

Arrays

Bash supports indexed arrays (since v2) and associative arrays / maps (since v4). They are one of the few features that genuinely make bash scripts cleaner than chained awk pipelines.

Indexed and associative arrays

# --- Indexed ---
declare -a hosts=(web01 web02 db01)
hosts+=(cache01)               # append
echo "${hosts[0]}"             # web01
echo "${hosts[@]}"             # all elements as separate words
echo "${#hosts[@]}"            # length: 4

# Iterate (quote the @, always)
for h in "${hosts[@]}"; do
    echo "checking $h"
done

# Slice
echo "${hosts[@]:1:2}"         # web02 db01

# --- Associative (bash 4+) ---
declare -A role
role[web01]=frontend
role[db01]=database

for host in "${!role[@]}"; do
    printf '%-10s %s\n' "$host" "${role[$host]}"
done

Pitfall: "${array[@]}" expands to N words; "${array[*]}" joins them with the first character of IFS. Use the former for iteration, the latter only when you really want a single joined string.

Conditionals: [[ ]] over [ ]

Bash has three test syntaxes. The portable [ ] (a.k.a. test) is fine, but its quoting rules are surprising and it doesn't understand &&, ||, or regex. Prefer [[ ]] in Bash scripts: it's safer, more readable, and Bash-specific in a script we already wrote in bash.

Test idioms

# String comparison
if [[ "$name" == "alex" ]]; then echo "hi"; fi
if [[ "$name" != "" ]]; then echo "have name"; fi
if [[ -z "$name" ]]; then echo "empty"; fi
if [[ -n "$name" ]]; then echo "non-empty"; fi

# Numeric comparison — use (( )) for integers
if (( count > 5 )); then echo "many"; fi
if (( count == 0 )); then echo "none"; fi

# File tests
if [[ -f /etc/passwd ]]; then echo "file exists"; fi
if [[ -d /var/log    ]]; then echo "dir exists"; fi
if [[ -r config.ini  ]]; then echo "readable"; fi
if [[ -x ./script.sh ]]; then echo "executable"; fi

# Combined conditions
if [[ -f "$f" && -r "$f" ]]; then ...; fi
if [[ "$env" == "prod" || "$env" == "stage" ]]; then ...; fi

# Pattern matching (glob)
case "$1" in
    -h|--help) usage ;;
    -v)        verbose=1 ;;
    --)        shift; break ;;
    -*)        echo "unknown flag: $1" >&2; exit 64 ;;
    *)         break ;;
esac

# Regex (only in [[ ]])
if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
    echo "looks like an IPv4 address"
fi

Arithmetic with (( ))

Integer arithmetic lives inside double parentheses. Inside (( )) variables don't need a $ prefix, and C-style operators work as you'd expect.

Arithmetic examples

count=0
(( count++ ))                   # post-increment
(( total = a + b * c ))
(( percent = (used * 100) / total ))

# Use $(( ... )) when you need the value, not just the side effect
size_kb=$(( size_bytes / 1024 ))
remainder=$(( $RANDOM % 10 ))   # 0..9

# Watch out for the -e trap: a (( ... )) that evaluates to 0 returns 1
(( i = 0 )) || true

Bash arithmetic is integer-only. For floating point, shell out to awk or bc:

Float math

pi=$(awk 'BEGIN { print 4 * atan2(1, 1) }')
echo "$pi"   # 3.14159...

# Or with bc
echo "scale=4; 22/7" | bc      # 3.1428

Loops

Three loop forms cover almost everything:

for, while, until

# for-each over a list (with proper quoting)
for f in "$@"; do
    echo "processing $f"
done

# C-style for
for (( i = 0; i < 10; i++ )); do
    echo "i=$i"
done

# Range expansion (brace expansion — happens before $variable expansion)
for n in {1..5}; do echo "$n"; done

# WARNING: brace expansion does NOT expand variables
start=1; end=5
for n in {$start..$end}; do echo "$n"; done   # prints literally {1..5}!
# Use seq or C-style instead:
for (( n = start; n <= end; n++ )); do echo "$n"; done

# while with a condition
count=0
while (( count < 3 )); do
    echo "$count"
    (( count++ ))
done

# until — like while, but runs *until* the condition is true
until ping -c1 -W1 db01 >/dev/null 2>&1; do
    echo "waiting for db01..."
    sleep 2
done

Reading from stdin and files

The right way to read a file line-by-line is the slightly verbose but correct idiom below. It preserves leading/trailing whitespace and handles backslashes literally.

Read each line of a file

while IFS= read -r line; do
    echo "got: $line"
done < /etc/hosts

# Stream from a command — note the < <(...) process substitution
while IFS= read -r line; do
    echo "ip: $line"
done < <(awk '{print $1}' /etc/hosts)

# CSV-style: tab-separated columns into named variables
while IFS=$'\t' read -r name role port; do
    echo "$name ($role) on $port"
done < services.tsv

Why not for line in $(cat file)? Because it splits on IFS (whitespace by default), drops empty lines, and glob-expands anything that looks like a glob. The while read idiom does none of those.

Functions

Functions group logic and make scripts testable. Always declare your variables local inside functions — Bash variables are global by default and forgetting local is one of the easiest ways to introduce a Heisenbug.

Function basics

log() {
    local level="$1"
    shift
    printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$level" "$*" >&2
}

require_root() {
    if (( EUID != 0 )); then
        log ERROR "must run as root"
        return 1
    fi
}

# Return values: 0 = success, anything else = error. Bash only stores
# a small int — use globals or stdout for richer "return values".
sum() {
    local a="$1" b="$2"
    echo $(( a + b ))
}

result="$(sum 3 4)"   # capture stdout
echo "$result"        # 7

Command Substitution and Here-docs

Capture command output with $(...). Avoid the older backtick form — it doesn't nest cleanly and is easy to mis-read.

Substitution and heredoc styles

# Modern: $(...) — nests, quotes cleanly
today="$(date +%F)"
files="$(find . -type f -name '*.log' | wc -l)"

# Here-doc — multi-line literal, supports expansion
cat <<EOF > /tmp/report.txt
Report for $(hostname) on $today
=================================
Found $files log files in $(pwd).
EOF

# Quoted heredoc tag (<<'EOF') disables expansion — useful for code blobs
cat <<'EOF' > /tmp/install.sh
#!/usr/bin/env bash
echo "$HOME"   # this stays literal in the written file
EOF

# Here-string — pipe a single string to stdin
read -r year month day <<< "$(date '+%Y %m %d')"
echo "$year / $month / $day"

Traps and Cleanup

If your script creates temp files, holds locks, or spawns background jobs, you need a trap handler so cleanup runs no matter how the script exits.

Idiomatic cleanup pattern

tmpdir="$(mktemp -d)"

cleanup() {
    local rc=$?
    rm -rf "$tmpdir"
    exit "$rc"
}
trap cleanup EXIT INT TERM

# Optional: print a stack trace on errors when we hit set -e
on_err() {
    local exit_code=$?
    log ERROR "command failed: '$BASH_COMMAND' (line ${BASH_LINENO[0]}, exit $exit_code)"
    exit "$exit_code"
}
trap on_err ERR

# ...do work using "$tmpdir"...

EXIT fires on any termination, normal or otherwise. INT handles Ctrl-C. TERM handles a polite kill. The ERR trap (only useful with set -E) fires the moment set -e would have killed the script — perfect for a "where did this die?" message.

Use ShellCheck

ShellCheck (shellcheck.net) is a static analyser specifically for shell scripts. It catches the entire "I forgot to quote" / "I used = in [ ]" / "$(cat file) instead of redirection" family of bugs. Install it on every dev machine and wire it into CI.

Run it locally

# Debian / Ubuntu
sudo apt-get install -y shellcheck

# RHEL / Rocky / AlmaLinux
sudo dnf install -y ShellCheck

# macOS
brew install shellcheck

shellcheck ./backup-rotate.sh

# Disable a specific check inline only when you're certain it's a false positive
# shellcheck disable=SC2086
echo $intentionally_unquoted

A Complete Example: Backup Rotation

Putting it all together: a script that archives a directory, places the archive in a backups folder, and keeps only the N most-recent archives. It illustrates strict mode, argument parsing, functions, traps, and safe filename handling.

backup-rotate.sh

#!/usr/bin/env bash
#
# backup-rotate.sh — archive SOURCE into DEST and keep KEEP most recent backups
#
# Usage: backup-rotate.sh SOURCE DEST [KEEP]
# Example: backup-rotate.sh /var/www /srv/backups 7

set -Eeuo pipefail
IFS=$'\n\t'

log() {
    printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$1" "${*:2}" >&2
}

usage() {
    cat <<EOF
Usage: $(basename "$0") SOURCE DEST [KEEP]
  SOURCE  Directory to archive
  DEST    Directory to place the archive in (created if missing)
  KEEP    Number of archives to retain (default: 7)
EOF
    exit 64
}

(( $# >= 2 )) || usage

src="$1"
dst="$2"
keep="${3:-7}"

[[ -d "$src" ]]      || { log ERROR "source '$src' not a directory"; exit 66; }
[[ "$keep" =~ ^[0-9]+$ ]] || { log ERROR "keep must be a positive integer"; exit 64; }

mkdir -p "$dst"

stamp="$(date '+%Y%m%dT%H%M%S')"
archive="${dst%/}/$(basename "$src")-${stamp}.tar.gz"

# Ensure we never leave a half-written archive behind
cleanup() {
    local rc=$?
    if (( rc != 0 )) && [[ -f "$archive" ]]; then
        log WARN "removing partial archive: $archive"
        rm -f "$archive"
    fi
    exit "$rc"
}
trap cleanup EXIT INT TERM

log INFO "creating $archive"
tar --create --gzip --file "$archive" --directory "$(dirname "$src")" "$(basename "$src")"
log INFO "archive size: $(du -h "$archive" | cut -f1)"

# Rotate: keep the $keep newest, delete the rest
mapfile -t archives < <(
    find "$dst" -maxdepth 1 -type f -name "$(basename "$src")-*.tar.gz" -printf '%T@ %p\n' \
        | sort -nr \
        | awk '{ $1=""; sub(/^ /,""); print }'
)

if (( ${#archives[@]} > keep )); then
    for stale in "${archives[@]:keep}"; do
        log INFO "pruning $stale"
        rm -f -- "$stale"
    done
fi

log INFO "done, ${#archives[@]} archives total (keep=$keep)"

A few things to notice in that script that we discussed above:

  • mapfile -t archives < <(...) populates an array from a command's stdout without spawning a subshell (so any variables it sets are visible afterwards).
  • find -printf '%T@ %p\n' emits modification-time-with-fractional-seconds first so we can sort numerically. Then awk strips the time back off. This is portable to weird filenames in a way that ls -t isn't.
  • rm -f -- "$stale" uses -- so a filename starting with - isn't treated as an option.
  • Exit codes follow sysexits.h conventions — 64 for usage, 66 for missing input. Predictable exits make wrapping the script in cron / systemd timers easier.

Further Reading

  • Bash Reference Manualman bash on any Linux box; the canonical source for parameter expansion, redirection rules, and shell options.
  • Greg Wooledge's Bash Pitfalls — a curated list of every common bash mistake (mywiki.wooledge.org/BashPitfalls).
  • POSIX shell spec — useful when you genuinely need portable scripts. If you need bashisms, mark the script #!/usr/bin/env bash and own it.
  • shellcheck.net — paste any script in the browser, get instant lint output.

When a script grows past a few hundred lines and starts wanting structured data, error handling beyond exit codes, or genuine unit tests, it's a sign you've outgrown bash. Reach for Python, Go, or Rust at that point — but for everything below that threshold, well-written bash is fast to write, easy to read, and runs everywhere.