Parse CLI Flags Correctly in POSIX Shell with getopts
Handle short flags with arguments, distinguish missing from empty values, and produce actionable errors instead of silent defaults.
Task
Build a getopts loop that validates flags, supports -- as the escape hatch, and fails with a message naming the exact misuse.
When to use this
A deploy script takes -e for environment. Someone ran it with -e but no value; getopts silently consumed the next flag, and the script deployed to the string f.
Prerequisites
- Bash or POSIX sh with getopts builtin
- A script with at least one flag and one flag-with-argument
- Test cases for happy path, missing value, and unknown flag
Steps
1. Write the getopts loop with explicit validation per flag.
#!/usr/bin/env bash
set -euo pipefail
ENV=""; REGION=""; VERBOSE=0; DRY_RUN=0
usage() {
cat <<EOF
usage: $(basename "$0") [-v] [-n] -e ENV [-r REGION] [target]
-e ENV environment: staging|prod (required)
-r REGION region (default: from ENV)
-v verbose
-n dry run
EOF
exit 2
}
while getopts ":e:r:vn" opt; do
case "$opt" in
e) ENV=$OPTARG ;;
r) REGION=$OPTARG ;;
v) VERBOSE=1 ;;
n) DRY_RUN=1 ;;
:) echo "error: -$OPTARG requires a value" >&2; usage ;;
\?) echo "error: unknown flag -$OPTARG" >&2; usage ;;
esac
done
shift $((OPTIND - 1))
[ -n "$ENV" ] || { echo "error: -e ENV is required" >&2; usage; }
case "$ENV" in staging|prod) ;; *) echo "error: ENV must be staging|prod, got \"$ENV\"" >&2; usage ;; esac
Expected output: Missing values and unknown flags each fail with a message naming the flag, and the required-value check catches a silently empty ENV.
2. Test the three failure modes deliberately.
bash deploy.sh -e 2>&1 | head -2; bash deploy.sh -x 2>&1 | head -2; bash deploy.sh 2>&1 | head -2; echo "--- happy:"; bash deploy.sh -e staging -v target1 2>&1 | head -2Expected output: Each misuse prints its specific error plus usage: requires a value, unknown flag -x, and -e ENV is required, while the happy path proceeds.
3. Support the -- escape hatch for positional args starting with a dash.
TARGET=${1:-}
shift $((OPTIND - 1))
# caller can now say:
# deploy.sh -e prod -- -weird-name
# and TARGET receives "-weird-name" verbatim
if [ "${1:-}" = "--" ]; then shift; fi
TARGET=${1:-}
[ -n "$TARGET" ] || { echo "error: target required" >&2; usage; }
Expected output: A positional argument that begins with a dash survives intact instead of being parsed as a flag and swallowed.
4. Distinguish an empty value from a missing one explicitly.
e) # -e "" (empty) is different from -e (missing)
if [ -z "$OPTARG" ]; then
echo "error: -e value is empty" >&2; usage
fi
ENV=$OPTARG ;;
Expected output: An explicitly empty flag value now fails loudly at parse time instead of flowing downstream as an empty environment name.
5. Prove the argument grammar with a table test.
# test-args.sh -- table-driven, one line per case
run() { # run <expect-code> <args...>
local expect=$1; shift
set -- "$@"
out=$(bash deploy.sh "$@" 2>&1); code=$?
if [ "$code" -ne "$expect" ]; then
echo "FAIL args=[$*] want=$expect got=$code out=$out"; exit 1
fi
echo "ok [$*] -> $code"
}
run 2 -e
run 2 -z
run 2 ""
run 2 -e ""
run 0 -e staging
run 0 -e prod -v -- -odd-name
Expected output: Each grammar row prints ok with its exit code, and the table runs in CI to keep the parser from regressing silently.
Troubleshooting
Problem
Long options like --env are needed
Solution
getopts is short-flags-only; either normalize long flags to short before the loop (a pre-pass translating --env X to -e X), or accept the constraint and document short flags, since portable POSIX offers no long-option builtin.
Problem
OPTARG swallows the next flag when value is missing
Solution
That is the classic -e -v bug: use the leading colon in the optstring (":e:r:") so a missing value yields the : case instead of consuming the next token as the value.
Problem
Flags after positional arguments are ignored
Solution
getopts stops at the first non-option; either require flags-first (documented in usage) or loop getopts a second time after shifting positionals, which handles the interspersed style.
Limitations
- getopts handles single-character flags only; richer grammars belong in a language with a real arg parser, not in emulated shell state machines.
- The table test verifies the grammar, not the semantics of what each flag does; keep behavioral tests separate.
A flag parser that fails silently is a deployment hazard
The scariest shell bugs are argument bugs: a missing value silently becomes the next flag, an empty string becomes a truthy check, a target named -weird becomes a flag. getopts with the leading-colon optstring and explicit validation closes every one of these holes in twenty lines.
# the optstring colon is the whole trick:
# ":e:r:vn" -> silent errors you handle yourself
# "e:r:vn" -> getopts prints its own message and you lose control
while getopts ":e:r:vn" opt; do
case "$opt" in
e) ENV=$OPTARG ;;
:) echo "error: -$OPTARG requires a value" >&2; usage ;;
\?) echo "error: unknown flag -$OPTARG" >&2; usage ;;
esac
done
shift $((OPTIND - 1))
# grammar table proving it
# ok [-e] -> 2 (requires a value)
# ok [-z] -> 2 (unknown flag)
# ok [-e ""] -> 2 (empty value)
# ok [-e prod] -> 0
The leading colon is control, not decoration
Without it, getopts prints its own diagnostic and sets opt to ?, losing the information of which flag failed and whether it was missing-argument versus unknown. With it, the : and ? cases receive the offending flag in OPTARG, so your errors name the exact misuse and your usage text stays the single source of truth.
Test the grammar like code
Six rows of run-expectation cover the entire misuse surface, cost nothing to run, and belong in CI next to the script. Argument parsing is the most regression-prone part of any CLI, precisely because manual testing always exercises the happy path first and only.