A deploy script accepted its target environment as the first positional argument: ENV="$1". A teammate, reasonably, ran it as ./deploy.sh --env prod, expecting production. The script read $1 — which was the literal string --env — as the environment name, looked for a config directory called --env, did not find one, and fell through to its default, which happened to be staging. The production deploy never executed. There was no error, because from the script's point of view nothing had gone wrong: it deployed to staging exactly as its (silent) default instructed. The production service ran the previous, degraded build for 40 minutes before someone checking the dashboard noticed traffic errors. Argument parsing is the discipline that turns that silent fallthrough into a loud, immediate unknown option: --env — a refusal instead of a guess.
Positional arguments with defaults and validation
The simplest input is ordered: $1, $2, $3. Give optional arguments a default and make required ones mandatory:
${1:-info} supplies info when no first argument is given, without assigning anything. ${2:?message} stops the script immediately with your message if the argument is absent. That single character — :? instead of :- — is the line between "fail loudly now" and "guess and corrupt later."
getopts for short flags
For POSIX single-letter flags, getopts is built in and reliable. A trailing colon after a letter means that flag takes an argument, delivered in $OPTARG:
The shift $((OPTIND - 1)) at the end discards everything getopts consumed, so any remaining $1, $2 are the positional arguments that came after the flags. getopts handles bundling (-vf file) and reports errors for unknown flags — but it speaks only short options.
Long options with while + case
getopts cannot parse --verbose or --file=path. For GNU-style long flags — which is what most users expect in 2026 — write the parse loop by hand:
Each branch shifts past what it consumed — shift for a boolean, shift 2 for a flag plus its separate-word value. The --file=* branch handles the --file=path spelling by stripping everything up to and including the = with ${1#*=}. The -*) catch-all rejects unknown flags loudly, which is precisely the protection the deploy script lacked.
Combining positional args and flags
Real scripts take both — a positional source plus optional flags. The --) shift; break and *) break branches above hand control back to your positional handling once flags are exhausted, so after the loop the remaining "$@" are your positional arguments.
The -- end-of-options sentinel
-- tells a command "no more flags follow." It matters both when you call other commands and when parsing your own input. If a filename legitimately starts with a dash — -recovery.log — then rm "$file" would treat it as a flag and error out, but rm -- "$file" forces it to be read as a filename. Pass -- through to subcommands when any argument value could begin with a dash.
Validating positional arguments and subcommand dispatch
After the flag loop consumes options, the remaining "$@" are your positional arguments. Validate their count before using them, the same way you validated required flags:
For tools that group behavior under verbs — script.sh deploy, script.sh rollback, the model git and docker use — dispatch on the first positional argument with a case:
The shift removes the verb so each handler function sees only its own arguments, and the *) branch rejects an unknown subcommand with the same loud failure you give an unknown flag. This is how one script grows from a single action into a small CLI without collapsing into a tangle of nested if statements — and every unrecognized input still fails visibly instead of falling through to a silent default.
Complete production script
A backup script accepting --source, --dest, --dry-run, and --days, with a usage() function, required-argument validation, and clear error messages.
The flags can arrive in any order, missing required arguments are caught before a single file moves, and --dry-run lets a user verify the resolved paths and retention window before committing. The script refuses bad input instead of guessing — which is the entire job of argument parsing, and the whole reason that 40-minute degraded deploy could not happen here.