getopts Explained: Parsing Script Arguments Without Regret
A script that reads $1, $2 and $3 works perfectly until someone passes the arguments in a different order, or forgets one, or wants a flag. getopts is built into bash and solves that for short options.
The problem
#!/usr/bin/env bash
# the fragile version
input="$1"
output="$2"
verbose="$3"
Every one of these breaks it: swapping the arguments, omitting the third, passing -v instead of a word, passing a filename that starts with a dash.
#!/usr/bin/env bash
# the manual version, which grows unpleasantly
while [ $# -gt 0 ]; do
case "$1" in
-v) verbose=1 ;;
-o) output="$2"; shift ;;
*) input="$1" ;;
esac
shift
done
That is workable, and it does not handle bundled flags like -vo file, does not report missing arguments, and gets longer with every option.
getopts
#!/usr/bin/env bash
set -euo pipefail
verbose=0
output=""
while getopts "vo:h" opt; do
case "$opt" in
v) verbose=1 ;;
o) output="$OPTARG" ;;
h) echo "usage: $0 [-v] [-o FILE] INPUT"; exit 0 ;;
*) echo "unknown option" >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
input="${1:-}"
Five things are happening.
The option string "vo:h" lists valid letters. A colon after a letter means that option takes an argument. So -v and -h are flags, -o requires a value.
$opt holds the letter found on this iteration.
$OPTARG holds the argument, for options that take one.
$OPTIND is the index of the next argument to process.
shift $((OPTIND - 1)) discards everything consumed, leaving the remaining arguments as $1 onward. Forgetting this line is the single most common getopts mistake, and the symptom is that your positional arguments appear to be missing.
What you get for free: flags in any order, bundling so -vo out.txt works, and -- to end option parsing so a filename beginning with a dash can be passed.
Handling errors yourself
By default getopts prints its own messages, which do not match your script’s style.
while getopts ":vo:h" opt; do
case "$opt" in
v) verbose=1 ;;
o) output="$OPTARG" ;;
h) usage; exit 0 ;;
:) echo "$0: option -$OPTARG requires an argument" >&2; usage; exit 2 ;;
\?) echo "$0: unknown option -$OPTARG" >&2; usage; exit 2 ;;
esac
done
A leading colon in the option string switches on silent mode. Then:
:as the case branch means a required argument was missing, with the letter inOPTARG\?means an unknown option, with the letter inOPTARG
This is the form worth using in anything other people will run. The messages are yours, and they go to stderr with a non-zero exit code, which is what a caller expects.
A complete script
#!/usr/bin/env bash
set -euo pipefail
readonly PROGNAME="${0##*/}"
usage() {
cat <<EOF
usage: $PROGNAME [options] SOURCE...
Options:
-o FILE write output to FILE (default: stdout)
-j N run N jobs in parallel (default: 1)
-n dry run, show what would happen
-v verbose, repeat for more
-h show this help
EOF
}
die() { printf '%s: %s\n' "$PROGNAME" "$*" >&2; exit 2; }
output=""
jobs=1
dry_run=0
verbosity=0
while getopts ":o:j:nvh" opt; do
case "$opt" in
o) output="$OPTARG" ;;
j) [[ "$OPTARG" =~ ^[0-9]+$ ]] || die "-j needs a number, got '$OPTARG'"
jobs="$OPTARG" ;;
n) dry_run=1 ;;
v) (( verbosity++ )) ;;
h) usage; exit 0 ;;
:) die "option -$OPTARG requires an argument" ;;
\?) die "unknown option -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
(( $# > 0 )) || { usage >&2; die "no source given"; }
(( verbosity > 0 )) && printf 'jobs=%d dry_run=%d sources=%d\n' \
"$jobs" "$dry_run" "$#" >&2
for src in "$@"; do
[[ -e "$src" ]] || die "no such file: $src"
(( dry_run )) && { echo "would process $src"; continue; }
# real work here
printf 'processing %s\n' "$src"
done
Points worth lifting from that:
Validate arguments as you parse them. -j abc should fail at parse time with a clear message, not later with an arithmetic error.
-v counting up lets -vv mean more verbose, which is a common convention and costs one line.
Usage goes to stderr on error, stdout on -h. That distinction matters when someone pipes the output.
Quote "$@" always. Our quoting guide covers why, and it is the difference between handling filenames with spaces and not.
Combine with the patterns in our error handling guide and conditionals and loops guide.
Where getopts stops
It handles short options only. There is no way to make the builtin accept --verbose.
It stops at the first non-option argument. So this does not work as people expect:
./script file.txt -v # -v is never parsed
./script -v file.txt # correct
That is POSIX behaviour rather than a bug: everything after the first operand is an operand. GNU tools permute arguments so both orders work, and getopts does not.
Long options
Two routes.
GNU getopt
#!/usr/bin/env bash
set -euo pipefail
# getopt --test exits 4 only in GNU enhanced mode, so capture that
# rather than letting set -e abort on the non-zero status
getopt_status=0
getopt --test >/dev/null 2>&1 || getopt_status=$?
if (( getopt_status != 4 )); then
echo "GNU enhanced getopt required" >&2
exit 1
fi
SHORT=o:j:nvh
LONG=output:,jobs:,dry-run,verbose,help
PARSED=$(getopt --options="$SHORT" --longoptions="$LONG" --name "$0" -- "$@") \
|| exit 2
eval set -- "$PARSED"
output="" jobs=1 dry_run=0 verbosity=0
while true; do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
-j|--jobs) jobs="$2"; shift 2 ;;
-n|--dry-run) dry_run=1; shift ;;
-v|--verbose) (( verbosity++ )); shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
*) echo "parse error" >&2; exit 3 ;;
esac
done
eval set -- "$PARSED" is required and looks alarming. GNU getopt in enhanced mode outputs its result with proper shell quoting precisely so that eval is safe here, and the quoting around "$PARSED" is what makes it so. This is the one context in which eval on program output is the documented approach rather than a mistake.
Also note it accepts abbreviations: --out works for --output as long as it is unambiguous.
The catch is portability. GNU getopt is not on macOS or BSD, where getopt is the old version that cannot do long options and mangles anything with spaces. Hence the --test check, which returns 4 only on the enhanced version.
A manual loop
while (( $# )); do
case "$1" in
-o|--output) output="$2"; shift 2 ;;
--output=*) output="${1#*=}"; shift ;;
-j|--jobs) jobs="$2"; shift 2 ;;
--jobs=*) jobs="${1#*=}"; shift ;;
-n|--dry-run) dry_run=1; shift ;;
-v|--verbose) (( verbosity++ )); shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) die "unknown option: $1" ;;
*) args+=("$1"); shift ;;
esac
done
set -- "${args[@]:-}" "$@"
Longer, no dependencies, works everywhere, and you control every behaviour. The --option=value branches use parameter expansion covered in our arrays and expansion guide.
This is what most real scripts do, and it is a defensible choice. The -* catch-all before the fallthrough is important: without it, a mistyped option is silently treated as a filename.
Deciding which
| Situation | Use |
|---|---|
| Short options only | getopts builtin |
| Long options, Linux only | GNU getopt |
| Long options, portable | Manual while and case |
| One or two arguments, personal script | Positional, honestly |
| Genuinely complex CLI | Not bash |
That last row matters. Subcommands, mutually exclusive groups, repeated options building lists, shell completion: at that point argument parsing is most of your script, and Python’s argparse or Go’s flag will take less of your life.
Adding completion
Once a script has proper options, completion is cheap:
# /etc/bash_completion.d/myscript
_myscript() {
local cur="${COMP_WORDS[COMP_CWORD]}"
if [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "-o -j -n -v -h --output --jobs --dry-run --verbose --help" -- "$cur"))
else
COMPREPLY=($(compgen -f -- "$cur"))
fi
}
complete -F _myscript myscript
Our shell completion guide covers doing it properly.
Testing the parsing
# does it handle each of these correctly
./script -v -o out.txt in.txt
./script -vo out.txt in.txt # bundled
./script -o out.txt -- -weird.txt # filename starting with a dash
./script -o # missing argument
./script -z # unknown option
./script # no arguments
./script -j abc in.txt # bad value
Those seven cases catch nearly every parsing bug. Run them after any change to the option handling, ideally from a test script rather than by hand.
Frequently Asked Questions
What is the difference between getopts and getopt?
getopts is a bash builtin that handles short options only and is safe and portable. getopt is an external program whose GNU version handles long options and quoting correctly through its enhanced mode. Use the builtin unless you need long options, and if you do, use GNU getopt with eval set and its quoted output.
How do I support long options like —verbose in bash?
The builtin getopts cannot do it. Either use GNU getopt in enhanced mode, or write a while loop over the arguments with a case statement, which is what many scripts do because it needs no external dependency and reads clearly.
What does the colon in the getopts option string mean?
A colon after a letter means that option takes an argument, which lands in OPTARG. A colon at the very start of the string switches on silent error reporting, so your own case statement handles invalid options and missing arguments instead of getopts printing its own message.
Why do my positional arguments disappear after getopts?
They do not, but they are still behind the parsed options until you remove them. After the parsing loop, run shift with OPTIND minus one, which discards everything getopts consumed and leaves the remaining arguments as the first positional parameter onward.
Why does getopts stop parsing halfway through my arguments?
Because it stops at the first non-option argument, which is correct behaviour. A command written as script file.txt -v leaves the flag unparsed, since everything after the first operand is treated as an operand. Options go before positional arguments unless you use GNU getopt, which reorders them.
Should I use getopts in a script that other people will run?
Yes, and give it a usage function and a help option too. A script that accepts flags in any order, reports unknown options clearly, and prints usage when asked is markedly less annoying to use than one that reads the first three positional parameters and hopes.