Why getopts beats hand‑rolled parsing

Early in my career I wrote a deployment script that accepted a handful of flags. I parsed $1, $2 manually, and it worked — until a user added an option after a positional argument. The script silently mis‑behaved, and debugging took hours. Since then I always reach for getopts. It’s part of POSIX, handles option grouping, validates required arguments, and gives a clean ? case for unknown flags.

Real‑world scenario: a backup wrapper

Imagine a wrapper around rsync that teams use nightly. It needs a source directory, a destination host, optional bandwidth limit, and a dry‑run flag. The script must be callable from cron, from CI pipelines, and interactively. A predictable, self‑documenting interface prevents “works on my machine” bugs.

The pattern

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

# ---------- defaults ----------
SRC=""
DEST=""
BWLIMIT=""
DRY_RUN=false
VERBOSE=false

# ---------- usage ----------
usage() {
    cat <<'EOF'
Usage: $(basename "$0\)) [options]
  -s    Source directory (required)
  -d  Destination (required)
  -b    Bandwidth limit for rsync (optional)
  -n          Dry‑run (pass -n to rsync)
  -v          Verbose output
  -h          Show this help
EOF
}

# ---------- option parsing ----------
while getopts "s:d:b:nvh" opt; do
    case $opt in
        s) SRC=$OPTARG ;;
        d) DEST=$OPTARG ;;
        b) BWLIMIT=$OPTARG ;;
        n) DRY_RUN=true ;;
        v) VERBOSE=true ;;
        h) usage; exit 0 ;;
        \?) usage >&2; exit 1 ;;
    esac
done
shift $((OPTIND-1))

# ---------- validation ----------
if [[ -z $SRC || -z $DEST ]]; then
    echo "Error: source and destination are required." >&2
    usage >&2
    exit 1
fi

# ---------- build rsync command ----------
RSYNC_CMD=(rsync -a)
$VERBOSE && RSYNC_CMD+=(-v)
$DRY_RUN && RSYNC_CMD+=(-n)
[[ -n $BWLIMIT ]] && RSYNC_CMD+=(--bwlimit="$BWLIMIT" )
RSYNC_CMD+=("${SRC%/}/" "$DEST" )

# ---------- execution ----------
if $VERBOSE; then
    echo "Running: ${RSYNC_CMD[*]}" >&2
fi
"${RSYNC_CMD[@]}"

Breaking down the pieces

  • set -euo pipefail – fails fast on unset variables, non‑zero exits, and pipeline errors.
  • Defaults first – makes the script’s contract visible at a glance.
  • Heredoc usage() – single‑quoted delimiter ('EOF') prevents variable expansion, so the help text stays literal.
  • getopts string"s:d:b:nvh" tells the parser which flags take arguments (colon) and which are boolean.
  • Case \?) – catches unknown options and prints usage to stderr.
  • shift $((OPTIND-1)) – removes parsed options, leaving any positional arguments (none in this example) for later processing.
  • Array for the commandRSYNC_CMD avoids word‑splitting pitfalls and makes it trivial to inject conditional flags.
  • Parameter expansion ${SRC%/}/ – guarantees a trailing slash for rsync’s “copy contents” semantics without double slashes.

Why this scales

Because the parsing logic lives in a single while getopts loop, adding a new flag is a matter of two lines: a letter in the optstring and a case branch. No fragile if [[ $1 == "-x" ]] chains that break when options are reordered. The script also plays nicely with set -u — all variables are defined before use.

Tip: If you need long options (--source) you can combine getopts with a pre‑processing loop that translates --long=value into -s value. Keeps the core parser tiny.

Testing the wrapper

# Dry‑run, verbose, 500 kbps limit
./backup.sh -s /data/app -d backup.example.com:/mnt/backups -b 500 -n -v

The output shows the exact rsync command before it runs, giving confidence that the generated flags match expectations.

Final thoughts

Investing a few minutes in a disciplined getopts skeleton pays off every time a colleague (or future you) adds a feature. The script stays readable, POSIX‑compatible, and — most importantly — predictable under automation. Next time you reach for $1 parsing, pause and give getopts a chance; your future self will thank you.