skip to content

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`.

level: seniorimportance: should knowfreq 45%

answer

  1. the order of arguments is not free
  2. it never rearranges the command line
  3. only one character per option
  4. the long name has to be matched by hand
  5. the external tool is not the builtin

basics

~20 s

getopts 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 s

Two 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
bash
#!/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

for a junior

Know the two limits: getopts handles single-letter options only, and options have to come before the file or target arguments.

for a middle

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.

for a senior

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.

for a principal

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

context