skip to content

Argument Parsing with getopts

getopts is the built-in way to parse short options, with OPTARG and OPTIND doing the bookkeeping, and it stops at the first non-option argument. Long options need a hand-written case loop, and the practical follow-up is how you treat `--` and print usage with a sensible exit code.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

5

A bash script parses flags with `while getopts "o:v" opt; do ... done`, then reads its input file from `$1` — but when it is run as `script -v data.csv`, `$1` is `-v` instead of `data.csv`. What is OPTIND, and which line is missing after the loop?

level: middleimportance: must knowfreq 62%

answer

  1. getopts reads, it does not consume
  2. the arguments are all still there
  3. a counter says how far it got
  4. one line right after done
  5. off by one is the trap

basics

~20 s

OPTIND is the index of the next argument getopts will examine, and getopts never alters the positional parameters itself. The missing line is shift $((OPTIND - 1)) after the loop, which drops the parsed options so operands start at $1.

solid answer

~50 s

`OPTIND` is the index of the next positional parameter getopts will look at. The shell sets it to 1 when the script starts, and getopts advances it as it consumes option words and their values — but getopts never modifies the positional parameters themselves. That is why `$1` is still `-v` when the loop ends. The missing line, immediately after `done`, is `shift $((OPTIND - 1))`: it discards exactly the words getopts consumed, including a terminating `--`, so the script's real operands become `$1`, `$2`, and so on. One gotcha follows from OPTIND being an ordinary variable: it is not reset when a loop finishes, so a function that parses its own arguments with getopts should declare `local OPTIND` (or set `OPTIND=1`) — otherwise its second call starts reading past the end and sees no options at all.

code

bash · 19 lines
bash
#!/usr/bin/env bash
set -euo pipefail

verbose=0
outfile=""

while getopts "o:v" opt; do
  case $opt in
    o) outfile=$OPTARG ;;
    v) verbose=1 ;;
  esac
done
shift $((OPTIND - 1))

printf 'verbose=%s outfile=%s\n' "$verbose" "$outfile"
printf 'operands: %s\n' "$*"

# ./s -v -o out.txt data.csv  ->  operands: data.csv
# without the shift line       ->  operands: -v -o out.txt data.csv

go deeper

for a junior

Memorise the pair: a getopts loop is always followed by shift $((OPTIND - 1)). Know that without it the flags are still sitting in $1 and $2.

for a middle

Explain why the subtraction is there — OPTIND indexes the next unread argument — and that getopts reads but never rewrites the positional parameters.

for a senior

Show you have debugged this: the give-away symptom is a script that works with flags alone or operands alone but not both, and the check is echoing "$@" right after the loop.

for a principal

Set the house pattern for argument handling so every script in the estate parses, shifts and validates identically, including how functions that reparse arguments isolate OPTIND.

## The bookkeeping variable `getopts` is stateless from one call to the next except for two shell variables it maintains: `OPTARG`, which carries the current option's value, and `OPTIND`, which is the **index of the next positional parameter to examine**. The shell initialises `OPTIND` to 1 each time a script or shell is invoked. Each call to getopts that consumes a whole argument word advances it. After a loop over `script -v -o out.txt data.csv`, `OPTIND` is 4: getopts consumed `$1` (`-v`), `$2` (`-o`) and `$3` (`out.txt`), so the next thing to look at is `$4`. ## Why the positional parameters are untouched This is the part that surprises people. getopts *reads* `$1`, `$2`, … and *reports* what it finds; it does not remove anything. When the loop condition finally fails — because getopts hit the end of the arguments, a `--`, or the first word that does not start with a dash — the positional parameters are exactly as the caller passed them. `$1` is still `-v`. The fix is one line, and it is the reason the idiom always appears together: ```bash while getopts "o:v" opt; do case $opt in o) outfile=$OPTARG ;; v) verbose=1 ;; esac done shift $((OPTIND - 1)) input=${1:?usage: script [-v] [-o FILE] INPUT} ``` ## Why OPTIND minus one `shift N` drops the first `N` positional parameters. `OPTIND` points at the next *unread* argument, counting from 1, so the number of words already consumed is `OPTIND - 1`. `$((...))` is arithmetic expansion; `shift $((OPTIND - 1))` therefore removes precisely the option words and leaves the operands starting at `$1`. Writing `shift $OPTIND` is an off-by-one that silently eats your first real argument, which is a genuinely nasty bug because it only shows up when arguments are present. If no options were given at all, `OPTIND` is still 1 and `shift 0` is a harmless no-op. ## What `--` does to the count An argument of exactly `--` ends option parsing. getopts consumes it and advances `OPTIND` past it, so the standard `shift $((OPTIND - 1))` removes the `--` along with the options. That is what makes `--` useful for operands that begin with a dash: ```bash script -v -- -weird-name.txt # after the loop and the shift: $1 is -weird-name.txt ``` Without the `--`, getopts would try to read `-weird-name.txt` as a bundle of option letters. ## OPTIND is not reset for you `OPTIND` is a plain shell variable, initialised once per shell invocation. Nothing resets it when a getopts loop finishes. In a script that parses its arguments exactly once, that never matters. It matters immediately when a *function* parses its own arguments: ```bash parse() { local OPTIND=1 opt while getopts "n:" opt; do case $opt in n) name=$OPTARG ;; esac done shift $((OPTIND - 1)) echo "rest: $*" } parse -n alice extra parse -n bob extra # without local OPTIND, this call sees no options ``` Declaring `local OPTIND` inside the function both gives the call a fresh counter and restores the caller's value on return. In a POSIX shell without `local`, save and restore, or assign `OPTIND=1` before each parse. ## Diagnosing it The symptom is always the same shape: the script works when you give it only options, or only operands, and mixes them up when you give both. Print `"$@"` right after `done` — if you see the flags still there, the `shift` is missing or wrong.

  • Why is `shift $OPTIND` wrong, and what does it do at runtime?
    OPTIND points at the next *unread* argument, so the number of consumed words is `OPTIND - 1`. `shift $OPTIND` removes one word too many, silently discarding the script's first real operand. It is invisible when no operands are passed, which is why it survives casual testing.
  • A user runs `script -v -- -o.txt`. What does $1 hold after the loop and the shift?
    `-o.txt`. The bare `--` terminates option parsing; getopts consumes it and advances OPTIND past it, so `shift $((OPTIND - 1))` removes both `-v` and `--`. That is the standard way to pass an operand whose name begins with a dash without it being read as options.
  • Does getopts ever modify $@ itself?
    No. getopts only reads the positional parameters and updates the variable you named, plus OPTARG and OPTIND. Every change to `$@` is yours — the `shift` after the loop, or an explicit `set --`. That separation is why forgetting the shift is such a common bug.

saying these in an interview costs you the question

  • Assumes getopts removes the options from $@
  • Writes shift $OPTIND instead of shift $((OPTIND - 1))
  • Puts the shift inside the loop body
  • Thinks OPTIND resets automatically after each loop
  • Reads operands with $2 and $3 rather than shifting first

context

open as a page

A bash script begins its option loop with `while getopts "hf:v" opt; do`. Which of h, f and v takes a value, where does bash put that value, and how are `-f out.txt`, `-fout.txt` and `-vf out.txt` each parsed?

level: juniorimportance: should knowfreq 52%

basics

~20 s

A trailing colon means that option takes a value: -f does, h and v do not. getopts puts the option letter in opt and -f's value in OPTARG, accepting -f out.txt, -fout.txt and -vf out.txt alike.

open as a page

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%

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

open as a page

A bash deploy script that parses flags with getopts kept running with its default settings when a user passed a mistyped flag, and separately a CI step that runs `./deploy.sh -h` is marked as a failed build. What is wrong with the script's option-parsing contract, and how should it signal a usage error versus a help request?

level: seniorimportance: should knowfreq 36%

basics

~20 s

An invalid option does not end a getopts loop — getopts still returns success — so a script with no error branch runs on defaults. Usage errors belong on stderr with a non-zero status such as 2; a -h help request belongs on stdout with exit 0.

open as a page

In bash, what changes when a getopts optstring begins with a colon, as in `while getopts ":f:v" opt`, compared with `"f:v"` — and how does the script then tell an unknown option from a missing option value?

level: middleimportance: nice to knowfreq 40%

basics

~20 s

A leading colon puts getopts in silent error mode: bash stops printing its own diagnostics, an unknown option sets the variable to ? and a missing value sets it to :, with the offending option letter in OPTARG so the script can report both itself.

open as a page