Skip to content

Bash patterns

Defensive patterns for scripts that run in production or CI.

set -euo pipefail

Put this at the top of every non-trivial script:

#!/usr/bin/env bash
set -euo pipefail
  • -e — exit immediately on any command returning non-zero.
  • -u — treat unset variables as an error.
  • -o pipefail — a pipeline fails if ANY command in it fails (not just the last).

Without pipefail, grep pattern file | wc -l returns 0 on success even if grep found nothing — the exit code is silently swallowed.

[[ ]] tests

Prefer [[ ]] over [ ] — it's a bash built-in, supports &&/|| inside and doesn't split on spaces:

file="/path/to/data.csv"

if [[ -f "$file" ]]; then
    echo "File exists"
fi

if [[ "$1" == "--dry-run" ]] || [[ "$1" == "-n" ]]; then
    DRY=true
fi

if [[ ${#items[@]} -eq 0 ]]; then
    echo "No items to process" >&2
    exit 1
fi

Loops

Iterate over an array:

services=(web worker scheduler)
for svc in "${services[@]}"; do
    echo "Restarting $svc"
    systemctl restart "$svc"
done

Loop over files (safe for names with spaces):

while IFS= read -r -d '' f; do
    echo "Processing: $f"
done < <(find . -name "*.log" -print0)

C-style loop for numeric ranges:

for ((i=1; i<=5; i++)); do
    echo "Attempt $i"
done

${var:-default}

Provide a default value when a variable is unset or empty:

LOG_LEVEL="${LOG_LEVEL:-info}"
OUTPUT_DIR="${OUTPUT_DIR:-/tmp/output}"

Related forms:

${var:?error message}   # exit with message if unset
${var:+replacement}     # substitute replacement if var IS set
${var%suffix}           # strip shortest matching suffix
${var##prefix}          # strip longest matching prefix

trap — cleanup on exit

Always clean up temp files, even on error or Ctrl-C:

TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT

# do work in $TMPDIR — it's always cleaned up
cp important_file "$TMPDIR/work.txt"

Trap multiple signals:

trap 'echo "Interrupted"; exit 130' INT TERM

Heredoc

Pass multi-line strings cleanly (no escaping):

cat > /etc/myapp/config.toml <<EOF
[server]
host = "0.0.0.0"
port = 8080
log_level = "${LOG_LEVEL}"
EOF

Use <<'EOF' (quoted) to prevent variable expansion inside the block.

Functions

Define reusable logic and keep scripts readable:

log() {
    local level="$1"; shift
    echo "[$(date -Iseconds)] [$level] $*" >&2
}

die() {
    log ERROR "$@"
    exit 1
}

check_dependency() {
    command -v "$1" >/dev/null 2>&1 || die "$1 is required but not installed"
}

check_dependency curl
check_dependency jq
log INFO "Dependencies OK"

getopts

Parse short options portably (POSIX, works in sh too):

usage() { echo "Usage: $0 [-v] [-o outfile] input"; exit 1; }

verbose=false
outfile="result.txt"

while getopts ":vo:" opt; do
    case $opt in
        v) verbose=true ;;
        o) outfile="$OPTARG" ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

input="${1:-}" 
[[ -z "$input" ]] && usage

See also: Python snippets for argparse when you need long options or subcommands.