A colleague reports that `./deploy.sh production --dry-run` ignores the flag completely, while `./deploy.sh -v production` works. The script parses its options with `while getopts "v" opt`. Explain both behaviours, and what your choices are for supporting `--dry-run`.
answer
- the order of arguments is not free
- it never rearranges the command line
- only one character per option
- the long name has to be matched by hand
- the external tool is not the builtin
basics
~20 sgetopts stops at the first argument that is not an option, so parsing ends at production and --dry-run is never examined. getopts also handles only single-character options; long options need a hand-written case loop over the arguments, or the Linux-only enhanced getopt(1).
solid answer
~50 sTwo separate limits are biting. First, `getopts` stops at the first word that does not begin with a dash — it does not permute arguments the way GNU `getopt_long` does — so once it reaches `production`, parsing ends and `--dry-run` is simply left in the positional parameters, unexamined. That is why the flag works before the operand and vanishes after it. Second, `getopts` parses only single-character options; there is no way to declare a long name in an optstring, and a word like `--dry-run` is read as short-option characters beginning with `-`, which is not in the optstring. Your realistic choices are: document that options come before operands and keep short flags; hand-write a `while`/`case` loop over the arguments that matches `--dry-run`, `--out=VALUE` and `--`; or use util-linux `getopt(1)` with `--long`, accepting that it is Linux-only and needs `eval set --`.
code
bash · 19 lines#!/usr/bin/env bash
set -euo pipefail
dry_run=0
out=""
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run) dry_run=1; shift ;;
--out) out=${2:?--out requires a value}; shift 2 ;;
--out=*) out=${1#*=}; shift ;;
-h|--help) printf 'usage: %s [--dry-run] [--out FILE] TARGET\n' "${0##*/}"; exit 0 ;;
--) shift; break ;;
-*) printf 'unknown option: %s\n' "$1" >&2; exit 2 ;;
*) break ;;
esac
done
printf 'dry_run=%s out=%s target=%s\n' "$dry_run" "$out" "${1-}"go deeper
Know the two limits: getopts handles single-letter options only, and options have to come before the file or target arguments.
Explain why the flag disappears — getopts stops at the first operand and does not permute — and sketch the while/case loop that handles a long name.
Weigh the options against the audience and the platforms: a strict short-option contract with a rejection check, a hand-written loop, or external getopt with its Linux-only and eval caveats.
Own the CLI contract across the team's tooling: whether flags may follow operands, how long names are spelled, and the threshold at which a script should be rewritten in a language with a real argument parser.
## Symptom one: parsing stops at the first operand `getopts` follows the POSIX utility syntax convention: options come first, then operands. It examines the argument at `OPTIND` and stops returning success as soon as it sees a word that does not start with `-`, is exactly `-`, or is exactly `--`. It does **not** reorder arguments. So with `./deploy.sh production --dry-run`, the very first call to getopts sees `production`, ends the loop, and leaves `OPTIND` at 1. Everything after it — including `--dry-run` — stays in the positional parameters. If the script then treats them as a list of targets, `--dry-run` is silently taken as a *second target*, which is worse than being ignored. This is a real behavioural difference from the C library on GNU systems, where `getopt_long` permutes the argument vector by default so options may appear anywhere. Scripts inherit the strict convention; users' fingers have learned the permissive one. ## Symptom two: there are no long options An optstring is a list of single characters. There is no syntax for `--dry-run`. If such a word does reach getopts (put it before the operand and you will see this), getopts strips the leading dash and reads the remaining characters as option letters — the first of which is another `-`, not in the optstring — so it reports an invalid option rather than an unknown long option. The failure is confusing precisely because the diagnostic talks about a single character. ## Choice one: keep getopts, fix the contract The cheapest fix is often the right one for an internal script: keep short options, state in the usage text that options precede operands, and make the script *reject* what it cannot parse. Add a check after `shift $((OPTIND - 1))` that no remaining operand starts with a dash, and exit with usage. That converts today's silent misbehaviour into a clear refusal. ```bash shift $((OPTIND - 1)) for arg in "$@"; do case $arg in -*) printf 'options must precede operands: %s\n' "$arg" >&2; exit 2 ;; esac done ``` ## Choice two: hand-write the loop When long options are genuinely required, the portable answer is a `while`/`case` loop over the positional parameters. It runs anywhere bash runs, has no external dependency, and lets you accept both `--out FILE` and `--out=FILE`: ```bash dry_run=0; out="" while [[ $# -gt 0 ]]; do case $1 in --dry-run) dry_run=1; shift ;; --out) out=${2:?--out requires a value}; shift 2 ;; --out=*) out=${1#*=}; shift ;; -h|--help) usage; exit 0 ;; --) shift; break ;; -*) printf 'unknown option: %s\n' "$1" >&2; exit 2 ;; *) break ;; esac done ``` The costs are real: you lose bundling (`-vf`) unless you implement it, you must remember `shift 2` for value-taking options and guard the missing-value case, and the `--` and unknown-option branches are easy to forget. Some scripts split the difference — a hand-rolled loop for the two or three long names, with getopts left in place for the short ones — but a single loop handling both is usually easier to read than two parsers taking turns. ## Choice three: the external getopt(1) The util-linux `getopt` command (Linux) supports long options and canonicalises the whole command line: ```bash parsed=$(getopt -o vo: --long verbose,out:,dry-run -n "${0##*/}" -- "$@") || exit 2 eval set -- "$parsed" ``` It is a genuinely capable parser, and it permutes, which is what users expect. Two caveats decide whether you can use it. It is an external program, not a builtin, and the `getopt` on macOS and the BSDs is the older implementation without `--long` — so a script relying on it is not portable to a developer laptop unless a GNU version is installed. And it works by printing a requoted command line that you feed to `eval set --`, so the quoting is only safe because `getopt` produced it; hand-editing that string reintroduces an injection risk. ## Choosing For a script run by its author and by CI, short options plus a strict operand check is usually enough. For a tool that other engineers type by hand, long options are worth the hand-written loop — names are self-documenting in a runbook. And when the interface starts wanting subcommands, mutually exclusive flags, repeated options and generated help, that is a signal the tool has outgrown shell argument parsing rather than a reason to build a parser in bash.
- Why does util-linux getopt's output have to be run through `eval set -- "$parsed"`?getopt prints the canonicalised command line as a single shell-quoted string, with operands moved after a `--`. `eval set --` re-parses that quoting and installs the result as the positional parameters, which is how values containing spaces survive. It is safe only because getopt produced the quoting; never hand-build the string you eval.
- How would you support `--out=FILE` as well as `--out FILE` in a hand-written loop?Add a second case branch with a glob: `--out=*) out=${1#*=}; shift ;;` alongside `--out) out=$2; shift 2 ;;`. The `${1#*=}` parameter expansion strips everything up to and including the first `=`. Guard the separated form against a missing value, for example with `${2:?--out requires a value}`.
- When would you stop parsing arguments in bash at all?When the interface needs subcommands with their own flags, repeated or mutually exclusive options, typed validation, or generated help — the point where the parser starts being the largest part of the script. That complexity is a symptom that the tool has outgrown shell, not a reason to write a parser framework in bash.
saying these in an interview costs you the question
- Says getopts supports long options if you spell them out
- Believes options may appear anywhere on the command line
- Confuses the getopts builtin with the external getopt command
- Assumes getopt --long works the same on macOS
- Ignores unparsed dash-prefixed operands after the loop