Robust Argument Parsing in Bash with getopts: A Production‑Ready Pattern
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 command –
RSYNC_CMDavoids 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 combinegetoptswith a pre‑processing loop that translates--long=valueinto-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.