Bash scripting reference: things I always forget

2025-11-30 — bash, scripting

A running collection of bash patterns I keep needing to look up. Not a tutorial — just the parts that don't stick in my head for whatever reason. Still adding to this.

Start every script with this

#!/usr/bin/env bash
set -euo pipefail
  • -e — exit immediately if any command fails (non-zero exit code)
  • -u — treat unset variables as errors
  • -o pipefail — a pipeline fails if any command in it fails, not just the last one

set -e alone isn't enough because pipelines like grep foo file | wc -l will mask a failing grep.

Variables and quoting

# Assignment — no spaces around =
NAME="alice"
COUNT=0

# Always quote variables in strings
echo "Hello, $NAME"
cp "$SOURCE" "$DEST"   # handles spaces in paths

# Braces are optional but good habit for clarity
echo "${NAME}s"   # "alices" — without braces: "$NAMEs" breaks

# Default values
: "${CONFIG_FILE:=/etc/myapp/config}"  # set if unset or empty
echo "${DEBUG:-false}"                 # use default without setting

# Fail with message if unset
: "${REQUIRED_VAR:?REQUIRED_VAR must be set}"

String manipulation

# Length
echo "${#NAME}"

# Substring: ${var:offset:length}
STR="hello world"
echo "${STR:6:5}"    # "world"

# Remove prefix (shortest match)
FILE="path/to/file.tar.gz"
echo "${FILE#*/}"    # "to/file.tar.gz"

# Remove prefix (longest match)
echo "${FILE##*/}"   # "file.tar.gz"  (same as basename)

# Remove suffix (shortest match)
echo "${FILE%.*}"    # "path/to/file.tar"

# Remove suffix (longest match)
echo "${FILE%%.*}"   # "path/to/file"

# Substitution
echo "${FILE/.tar.gz/.bak}"   # replace first match
echo "${FILE//l/L}"            # replace all matches

# Case conversion (bash 4+)
echo "${NAME^^}"    # ALICE
echo "${NAME,,}"    # alice

Conditionals

if [[ -f "$FILE" ]]; then
    echo "file exists"
elif [[ -d "$FILE" ]]; then
    echo "it's a directory"
else
    echo "not found"
fi

Use [[ ]] over [ ] in bash scripts — it handles empty variables and patterns without surprises.

Common test expressions

ExpressionTrue if
-f "$f"regular file exists
-d "$f"directory exists
-e "$f"anything exists at path
-r "$f"file is readable
-w "$f"file is writable
-x "$f"file is executable
-s "$f"file exists and is non-empty
-z "$s"string is empty
-n "$s"string is non-empty
"$a" = "$b"strings are equal
"$a" != "$b"strings are not equal
"$a" =~ regexstring matches regex (bash only)
$a -eq $bintegers are equal
$a -lt $binteger a is less than b
$a -ge $binteger a is greater than or equal to b

Loops

# Iterate over a list
for item in one two three; do
    echo "$item"
done

# Iterate over files
for f in /etc/*.conf; do
    echo "Processing $f"
done

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

# While loop
while IFS= read -r line; do
    echo "Line: $line"
done < /path/to/file

# Infinite loop with break
while true; do
    [[ -f /tmp/stop ]] && break
    sleep 5
done

Functions

log() {
    local level="$1"
    shift
    echo "[$(date '+%H:%M:%S')] [$level] $*" >&2
}

log INFO "Starting up"
log ERROR "Something went wrong"
  • local scopes variables to the function — use it to avoid clobbering globals
  • Functions return exit codes, not values. Capture output with $(funcname args)
  • $* joins all args as a single string; "$@" preserves each arg separately (usually want "$@")

Useful patterns

# Check if a command exists
if ! command -v jq >/dev/null 2>&1; then
    echo "jq is not installed" >&2
    exit 1
fi

# Get the directory of the current script (not $PWD)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Temp file that cleans up on exit
TMPFILE=$(mktemp)
trap 'rm -f "$TMPFILE"' EXIT

# Run something only if root
if [[ $EUID -ne 0 ]]; then
    echo "Must be run as root" >&2
    exit 1
fi

# Parse simple flags
VERBOSE=false
while [[ $# -gt 0 ]]; do
    case "$1" in
        -v|--verbose) VERBOSE=true; shift ;;
        --) shift; break ;;
        *) break ;;
    esac
done