Foundations Updated 2026-09 View as Markdown

Input Parameter Parsing

Turning $@ into a usable set of options: manual loops, getopts, long flags, and the --dry-run habit that makes a script safe to run once.

Every script that does anything useful eventually needs options. Bash gives you three levels of answer, and the right one depends on whether you need long flags.

The raw material#

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

echo "script name : $0"
echo "first arg   : ${1:-}"     # :- so an unset $1 does not trip set -u
echo "count       : $#"
echo "all args    : $*"         # one string
echo "all args    : $@"         # separate words — almost always what you want
shell
$ ./demo.sh alpha "two words"
script name : ./demo.sh
first arg   : alpha
count       : 2

shift#

shift discards $1 and moves everything down. It is what makes a parsing loop work.

shell
while [[ $# -gt 0 ]]; do
  echo "handling: $1"
  shift
done

Level 1: a manual loop#

The most common approach, because it handles long options and getopts does not.

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

usage() {
  cat <<'EOF'
Usage: backup.sh [options] <source> [<source>...]

Options:
  -d, --dest DIR      Destination directory (default: ./backups)
  -n, --dry-run       Print what would happen, change nothing
  -v, --verbose       More output (repeatable)
  -j, --jobs N        Parallel jobs (default: 4)
      --no-compress   Skip compression
  -h, --help          This message
EOF
}

dest="./backups"
dry_run=0
verbose=0
jobs=4
compress=1
sources=()

while [[ $# -gt 0 ]]; do
  case "$1" in
    -d|--dest)
      [[ $# -ge 2 ]] || { echo "$1 requires a value" >&2; exit 2; }
      dest="$2"; shift 2 ;;
    --dest=*)      dest="${1#*=}"; shift ;;
    -j|--jobs)
      [[ $# -ge 2 ]] || { echo "$1 requires a value" >&2; exit 2; }
      jobs="$2"; shift 2 ;;
    --jobs=*)      jobs="${1#*=}"; shift ;;
    -n|--dry-run)  dry_run=1; shift ;;
    -v|--verbose)  verbose=$((verbose + 1)); shift ;;
    --no-compress) compress=0; shift ;;
    -h|--help)     usage; exit 0 ;;
    --)            shift; sources+=("$@"); break ;;
    -*)            echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
    *)             sources+=("$1"); shift ;;
  esac
done

# --- validate -------------------------------------------------------
[[ ${#sources[@]} -gt 0 ]] || { echo "no source given" >&2; usage >&2; exit 2; }
[[ "$jobs" =~ ^[0-9]+$ ]]  || { echo "--jobs must be a number, got: $jobs" >&2; exit 2; }
[[ -d "$dest" ]] || mkdir -p -- "$dest"

for s in "${sources[@]}"; do
  [[ -e "$s" ]] || { echo "no such path: $s" >&2; exit 1; }
done

(( verbose )) && printf 'dest=%s jobs=%s compress=%s sources=%d\n' \
  "$dest" "$jobs" "$compress" "${#sources[@]}" >&2

(( dry_run )) && { printf 'would back up: %s\n' "${sources[@]}"; exit 0; }

Four details in there are worth naming, because they are the ones generated scripts miss:

  • -- ends option parsing. Everything after it is positional, even if it starts with a dash. Without it you cannot back up a file called -report.txt.
  • --dest=value handled separately. ${1#*=} strips up to the first =. Users expect both forms.
  • Missing values checked. [[ $# -ge 2 ]] before shift 2, otherwise --dest at the end of the line silently consumes nothing and set -u fires somewhere confusing.
  • Unknown options rejected. The -*) case. Silently ignoring a typo'd flag is how a script "works" while doing the wrong thing.

Level 2: getopts#

Built in, POSIX, tidier — and short options only. Good for small scripts.

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

dest="./backups"; dry_run=0; verbose=0

while getopts ":d:nvh" opt; do
  case "$opt" in
    d) dest="$OPTARG" ;;
    n) dry_run=1 ;;
    v) verbose=1 ;;
    h) usage; exit 0 ;;
    :)  echo "-$OPTARG requires a value" >&2; exit 2 ;;
    \?) echo "unknown option: -$OPTARG" >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))     # drop the parsed options; "$@" is now positional

The optstring is the whole configuration: a letter takes no value, a letter followed by : requires one, and a leading : switches on silent error reporting so your : and \? cases run instead of getopts printing its own message.

shift $((OPTIND - 1)) at the end is mandatory. Forget it and your positional arguments still have the options in front of them.

Defaults and environment overrides#

shell
dest="${BACKUP_DEST:-./backups}"      # env, then default
jobs="${JOBS:-4}"
: "${API_TOKEN:?API_TOKEN must be set}"   # required — fails with a clear message

That last form is worth memorising. ${VAR:?message} exits with your message if the variable is unset or empty, which is exactly what you want for anything required — and it is the safe way to interpolate a variable into a destructive command:

shell
rm -rf -- "${BUILD_DIR:?BUILD_DIR is not set}"/*

If BUILD_DIR is empty, the script exits instead of deleting from the root.

Always add --dry-run#

For any script that deletes, uploads, deploys or overwrites:

shell
run() {
  if (( dry_run )); then
    printf 'would run:'; printf ' %q' "$@"; printf '\n'
  else
    "$@"
  fi
}

run rm -rf -- "$tmp"
run rsync -a --delete -- "$src/" "$dest/"

%q quotes the output so you can copy the printed line and run it verbatim. This one function makes a script safe to try once, which matters enormously when the script was written by an agent.

Exercise#

shell
#!/bin/bash
# Parse these arguments and print the result.
# Support: -n/--name VALUE, -c/--count N (default 1), -q/--quiet, and
# positional arguments collected into an array.
# Print: name, count, quiet, then each positional on its own line.

set -euo pipefail
set -- --name Ada -c 3 -- report.txt "two words"

# write your code here

Common questions#

getopts or a manual while loop?#

getopts if short options are enough — it is shorter and handles bundling (-nv) for free. A manual loop the moment you want --long-options, which in practice is most scripts anyone else will run.

Why does -- matter?#

It marks the end of options, so anything after it is treated as a positional argument even if it begins with a dash. Without it, a filename like -report.txt is read as flags. The same reasoning is why you write rm -- "$f".

How do I make an option repeatable?#

Append to an array rather than assigning: -e|--exclude) excludes+=("$2"); shift 2 ;;. Then expand it with "${excludes[@]}". Counting flags like -vv work the same way with verbose=$((verbose + 1)).

Get the Bash agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Bash. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.