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?
answer
- getopts reads, it does not consume
- the arguments are all still there
- a counter says how far it got
- one line right after done
- off by one is the trap
basics
~20 sOPTIND 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#!/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.csvgo deeper
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.
Explain why the subtraction is there — OPTIND indexes the next unread argument — and that getopts reads but never rewrites the positional parameters.
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.
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