Hand-rolled $1/$2 argument handling works right up until someone reorders the flags, forgets a value, or passes a filename that starts with a dash. Then it fails silently, or worse, does the wrong thing without telling anyone.
Almost every shell script that survives contact with other people grows a command-line interface. The temptation is to read positional parameters directly — input=$1, output=$2 — because it takes ten seconds and works in the demo. It stops working the moment the script is used the way real tools are used: options in any order, short flags bundled, a --help that people actually read. Bash ships with getopts, a built-in that handles the tedious parts correctly. This is a copy-pasteable parsing block, built on bash 5.2, that handles short options with arguments, GNU-style long options, a -- terminator, sane defaults and a usage() wired to -h — without turning into spaghetti.
Why $1/$2 falls apart
Positional parsing assumes the caller passes arguments in exactly the order you imagined, with no optional flags in between. That breaks in ordinary use. If a user writes myscript --verbose input.txt instead of myscript input.txt, then $1 is now --verbose and your input path is empty. Add a second optional flag and the combinatorics explode. You end up writing a bespoke if/elif ladder for every ordering, and that ladder is where the bugs live: no validation of missing values, no handling of -abc bundling, no way to stop treating a leading-dash filename as a flag. getopts exists to remove that class of bug. It is a POSIX shell built-in — not the external getopt(1) binary, which behaves differently across systems — so it is available everywhere bash runs and needs no dependency.
Free · 4 minutes
Is your engineering team shipping safely, or quietly accumulating risk?
Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.
getopts, in the smallest form that is correct
getopts takes an option string and a variable name. Each call sets that variable to the next option letter and advances OPTIND, the index of the next argument to process. A letter followed by a colon in the option string means that option takes an argument, which getopts puts in OPTARG. The single most important detail: start the option string with a colon. That switches on silent error mode, where getopts hands you invalid options and missing arguments to handle yourself instead of printing its own message and pressing on.
while getopts ':g:o:' opt; do
case "$opt" in
g) greeting="$OPTARG" ;; # -g VALUE, OPTARG holds VALUE
o) output="$OPTARG" ;; # -o VALUE
:) echo "-$OPTARG needs an argument" >&2; exit 2 ;;
\?) echo "unknown option -$OPTARG" >&2; exit 2 ;;
esac
done
shift $(( OPTIND - 1 )) # drop the parsed options, leave the operands
With a leading colon in the option string, a missing argument sets opt to : and puts the offending letter in OPTARG; an unrecognised flag sets opt to ? with the same. Without the leading colon, getopts prints to stderr on your behalf and you lose control of the message and the exit code. Always take the colon. The closing shift $(( OPTIND - 1 )) discards everything getopts consumed, leaving "$@" holding only the positional operands.
Adding GNU-style long options
Bash’s getopts does not do long options. It never has, and it will not, because the POSIX utility syntax it implements only knows single letters. Rather than reach for the external getopt and its portability quirks, the robust pattern is a small pre-pass: walk the arguments once, rewrite each --long form into the short flag getopts already understands, then hand the rewritten list to getopts. This keeps one source of truth — the option string — and supports both --output file and --output=file spellings. Here is the whole thing as one script.
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: greet [OPTIONS] NAME...
Greet one or more people.
Options:
-g, --greeting TEXT Greeting word to use (default: Hello)
-o, --output FILE Write output to FILE instead of stdout
-c, --count N Repeat the greeting N times (default: 1)
-v, --verbose Print diagnostics to stderr
-h, --help Show this help and exit
USAGE
}
# Defaults -- set them once, up front.
greeting="Hello"
output=""
count=1
verbose=0
# 1. Translate GNU-style long options into their short equivalents.
args=()
while (( $# )); do
case "$1" in
--greeting) args+=(-g "$2"); shift 2 ;;
--greeting=*) args+=(-g "${1#*=}"); shift ;;
--output) args+=(-o "$2"); shift 2 ;;
--output=*) args+=(-o "${1#*=}"); shift ;;
--count) args+=(-c "$2"); shift 2 ;;
--count=*) args+=(-c "${1#*=}"); shift ;;
--verbose) args+=(-v); shift ;;
--help) args+=(-h); shift ;;
--) args+=(--); shift; while (( $# )); do args+=("$1"); shift; done ;;
--*) printf 'greet: unknown option: %s\n' "$1" >&2; exit 2 ;;
*) args+=("$1"); shift ;;
esac
done
set -- "${args[@]}"
# 2. Parse short options with getopts. Leading ':' turns on silent mode.
OPTIND=1
while getopts ':g:o:c:vh' opt; do
case "$opt" in
g) greeting="$OPTARG" ;;
o) output="$OPTARG" ;;
c) count="$OPTARG" ;;
v) verbose=1 ;;
h) usage; exit 0 ;;
:) printf 'greet: -%s requires an argument\n' "$OPTARG" >&2; exit 2 ;;
\?) printf 'greet: invalid option: -%s\n' "$OPTARG" >&2; exit 2 ;;
esac
done
shift $(( OPTIND - 1 ))
# 3. Validate what getopts cannot: value shape and required operands.
if ! [[ "$count" =~ ^[0-9]+$ ]] || (( count < 1 )); then
printf 'greet: --count must be a positive integer\n' >&2
exit 2
fi
if (( $# == 0 )); then
printf 'greet: at least one NAME is required\n' >&2
usage >&2
exit 2
fi
# Everything still in "$@" is a positional argument.
emit() {
local name i
for name in "$@"; do
for (( i = 0; i < count; i++ )); do
printf '%s, %s!\n' "$greeting" "$name"
done
done
}
(( verbose )) && printf 'greeting=%s count=%d output=%s\n' \
"$greeting" "$count" "${output:-<stdout>}" >&2
if [[ -n "$output" ]]; then
emit "$@" > "$output"
else
emit "$@"
fi
The three stages are deliberately separate. The first loop only translates long options to short ones and copies operands through untouched, including everything after a -- terminator. The second loop is pure getopts. The third does the validation getopts cannot do for you: getopts guarantees that -c received some argument, but only your own regex check confirms it is a positive integer rather than abc.
The rules getopts actually follows
Two behaviours surprise people and are worth stating plainly, because they are conventions rather than bugs. First, getopts stops at the first non-option argument. Options are expected before operands — greet -v Ada, not greet Ada -v. That is the standard Unix convention and almost every tool follows it; do not fight it. Second, always reset OPTIND=1 before a parsing loop if the script might parse arguments more than once, for instance inside a function. OPTIND is not reset automatically between calls, and a stale value is a genuinely baffling bug to chase.
Bundled short flags work for free: -vh is read as -v then -h. A flag that takes a value can be attached, so -cInput is equivalent to -c Input. None of that needs extra code — it is what the built-in already does once you have set the option string up correctly.
A usage() that carries its weight
The usage() function is not decoration. Wired to -h, it exits zero; printed to stderr on a parse error, it exits non-zero. That distinction matters: greet --help succeeding and greet --nonsense failing is the difference between a script that composes cleanly in a pipeline or CI job and one that confuses every tool downstream of it. Keep the help text in a single quoted here-document so it stays readable and there is exactly one place to update when you add a flag.
This kind of small, boring correctness is the same discipline that makes the rest of a build system trustworthy. The shell glue that runs in your pre-commit and CI enforcement hooks, or the scripts that emit the numbers behind your DORA delivery metrics, has to behave predictably when someone other than the author runs it. A parsing block that validates its input and exits with the right code is what stops a three-line script from quietly corrupting the job that calls it.
Copy the block, rename the flags, keep the leading colon and the three-stage structure. The ten seconds you saved with $1 and $2 is not worth the afternoon you lose to a reordered flag in production.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.