Bash getopts: Parse Script Arguments Without the $1 $2 Mess
The deploy script I inherited took two arguments: an environment and an optional dry-run flag. It worked every time I ran it, because I always ran it the way the previous author did. Then a new teammate ran ./deploy.sh -v staging, flags first. The script read $1 as the environment and tried to reach a host called -v. We spent an hour in the DNS configs before anyone opened the script.
Bash has a builtin for this called getopts, and once the pattern is written down it takes fifteen lines. Here is the version I paste into every script that takes more than one argument, with the traps I hit, measured on bash 3.2, which is what ships on this Mac.
The pattern
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
usage: ${0##*/} [-v] [-e ENV] [-o FILE] SOURCE...
-v verbose
-e ENV target environment (default: staging)
-o FILE write the log to FILE
EOF
}
die() { echo "${0##*/}: $*" >&2; exit 2; }
verbose=false env_name="staging" output=""
while getopts ":ve:o:h" opt; do
case "$opt" in
v) verbose=true ;;
e) env_name="$OPTARG" ;;
o) output="$OPTARG" ;;
h) usage; exit 0 ;;
:) die "option -$OPTARG requires an argument" ;;
\?) die "unknown option -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
The option string is the whole contract. A bare letter is a flag, a letter with a colon takes a value, and the value lands in $OPTARG. After the loop, shift $((OPTIND - 1)) drops everything getopts consumed, so "$@" holds only the real positional arguments. Forget that shift and your first source file is still -v, and the failure is confusing: the script looks like it lost its arguments.
Flags in any order, bundled short flags like -ve production, and a -h that exits 0 so a caller can tell asked-for-help from called-me-wrong.
Silent mode, and the exit code nobody uses
The leading colon in ":ve:o:h" matters. Without it, getopts prints its own complaint and hands you ? for both error cases. I ran a script declaring -v and -e with -z, then with a valueless -e: same branch both times. On stderr:
./deploy.sh: illegal option -- z
./deploy.sh: option requires an argument -- e
An unknown flag and a forgotten value arrive as the same ?, so your case cannot tell them apart. The leading colon switches to silent reporting: an unknown option comes back as ? with the letter in $OPTARG, a missing value as : with the letter in $OPTARG. Two branches, two messages, both in your own words.
Two details in that case are easy to get wrong. The unknown-option branch has to be written \?), because a bare ? in a case pattern matches any single character. I checked on bash 3.2: case "v" in ?) matches, so an unescaped pattern listed first swallows every option. And the exit code is 2, not 1. Two is the Unix convention for "you called me wrong", one for "I tried and failed".
Two ways it silently parses nothing
The first shows up when you move the loop into a function, which you should do the moment a script grows. OPTIND is an ordinary shell variable, and the bash manual is blunt about it: the shell does not reset OPTIND automatically, and it must be manually reset between multiple calls to getopts within the same shell invocation. So the second call to your parser reads zero options, resets every variable to its default, and exits successfully. I measured it: a parser called twice with the same arguments reported both options the first time and nothing at all the second.
parse_args() {
local OPTIND # that is the whole fix
local opt
while getopts "vq:" opt; do
case "$opt" in
v) verbose=true ;;
q) queue="$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
}
Declaring local OPTIND inside the function is enough, no =1 needed: the local starts empty and getopts initializes it. I ran three consecutive calls with the reset in place and all three parsed.
The second silent case is not a bug at all. getopts stops at the first argument that does not start with a dash. Run ./deploy.sh backup.tar -v and the -v is never seen as a flag; it stays in "$@" next to the file and your verbose mode stays off. Per the manual, when the end of options is encountered, OPTIND is set to the index of the first non-option argument. Most tools work this way, so the answer is to write "flags first" in the usage text.
What happens when a user types --verbose
getopts handles short options only, and what it does with a long one is worse than rejecting it. I passed --verbose to a script that declares -v and got this on stderr:
unknown option --
unknown option -e
unknown option -r
unknown option -b
unknown option -o
unknown option -s
unknown option -e
It reads the long option as a bundle of short ones. The v in "verbose" is a real flag, so verbose mode turns on by accident, and the rest of the word is rejected letter by letter. Pass --env=production and the v in "env" sets verbose too. A user who types a long flag gets a wall of nonsense instead of one clear error.
The usual advice is to reach for getopt, the external command, for long options. Be careful there, because getopt is two different programs. On this Mac, /usr/bin/getopt is the BSD version: its synopsis is args=$(getopt optstring $*), it has no long-option support, and it does not error when you feed it GNU syntax. I ran getopt -o v --long verbose,env: -- -v --env=production and it quietly quoted the whole line back at me as arguments. The util-linux version on Linux does support --long, so the same command behaves differently on the two systems. My scripts take short flags. If a tool genuinely needs --dry-run, I write the twenty-line while loop myself, or the script is telling me it wants to be a real program.
What I ship now
Every script that takes an argument gets the fifteen lines, the leading colon, local OPTIND if the parsing lives in a function, and a usage text that says flags first. The deploy script from the first paragraph has survived two more teammates and a migration to CI, and nobody has had to read it to call it correctly.
The template and the measured outputs from this post live in Snippet Ark, next to the strict mode snippet and the trap skeleton. Between them they cover most of what a shell script needs: fail fast, clean up after yourself, and know what you were called with.