Build the mental model
Parsing arguments by hand with a chain of if [ "$1" = "-v" ]; then ...; shift checks works for the simplest cases, but gets unreliable fast once flags can take values, appear in any order, or get combined (-vo report.txt versus -v -o report.txt). getopts is bash's built-in loop for doing this correctly: while getopts ":vo:h" opt; do case $opt in ... esac; done walks through the positional parameters one flag at a time, using the option string to know how to treat each letter — v and h are standalone flags, while the colon immediately after o in "vo:h" tells getopts that -o consumes the next argument as its value rather than being a flag on its own. Inside the loop, $opt holds the current flag letter on each iteration, $OPTARG holds that flag's value when it takes one, and $OPTIND is an index getopts increments internally to track how far into the argument list it has already consumed. Once every flag has been consumed, shift $((OPTIND - 1)) discards them all at once, leaving $1, $2, and so on as the real positional arguments — filenames, usually — that followed the flags. Two things worth knowing going in: getopts only understands short, single-letter flags like -v, not long GNU-style flags like --verbose (parsing those needs the separate getopt utility or hand-rolled logic), and OPTIND is not automatically reset between separate getopts loops run in the same shell session, so a script or test suite that runs the parsing loop more than once needs to reset OPTIND=1 first, or the second call silently picks up wherever the first one left off.
Connect it to a real scenario
A backup script needs to support ./backup.sh -v -o /mnt/backups file1.txt file2.txt: a verbose flag, an output directory that takes a value, and then a variable number of files to actually back up. Hand-rolled parsing with a chain of if-statements breaks the moment someone passes the flags in a different order, combines -v and -o, or puts the output path right after -o without a space; getopts handles every one of those standard conventions correctly without any extra code from you. Walking through the example: while getopts ":vo:h" opt does one iteration per flag, setting opt to v on the first pass (so the case statement sets verbose=1), then to o on the second pass, where $OPTARG is automatically set to report.txt so output=$OPTARG captures it. Once getopts has consumed both flags and their value, OPTIND points past them, so shift $((OPTIND - 1)) removes -v -o report.txt from the argument list entirely, leaving only somefile.txt anotherfile.txt behind in $1 and $2 — exactly the files the script actually needs to back up, regardless of how many flags came before them or in what order.
Try the working example
#!/bin/bash
set -euo pipefail
verbose=0
output="default.txt"
usage() {
echo "Usage: script.sh [-v] [-o file] [-h]"
}
# Simulate command-line arguments for this example
set -- -v -o report.txt somefile.txt anotherfile.txt
while getopts ":vo:h" opt; do
case $opt in
v)
verbose=1
;;
o)
output=$OPTARG
;;
h)
usage
exit 0
;;
\?)
echo "Unknown option: -$OPTARG" >&2
usage
exit 1
;;
:)
echo "Option -$OPTARG requires an argument" >&2
exit 1
;;
esac
done
shift $((OPTIND - 1))
echo "verbose=$verbose"
echo "output=$output"
echo "remaining positional args: $*"
Running the script above prints:
verbose=1
output=report.txt
remaining positional args: somefile.txt anotherfile.txt5-minute try-it
Extend the getopts loop to support a new -q (quiet) flag alongside the existing ones, and add a case that prints an error and exits if the same script is called with no flags at all.
One important caution
Forgetting the leading colon in the option string (":vo:h" vs "vo:h") — without it, getopts prints its own error messages for invalid options instead of letting your script handle them via the \? case
Forgetting shift $((OPTIND - 1)) after the loop, so $1 still refers to the first flag instead of the first real positional argument
GNU Bash Manual — Bash Builtins (getopts) — Bash / Shell Scripting