getopts in Bash: Parse Command-Line Options Properly
At some point, a script that started with $1 and $2 needs a -v flag, then a -d DIR, then a dry-run mode. If you parse short options by hand, the code quickly becomes hard to follow. This is worse once people start writing -nv instead of -n -v. Bash has a builtin for exactly this: getopts. It is simple, but it is available everywhere and needs no external program. It also handles the small, awkward cases for you.
How getopts works
getopts is called once per option, normally as the condition of a while loop. You give it an option string and a variable name. Each call puts the next option letter into the variable and returns 0. When there are no options left it returns 1 and the loop ends.
- The option string lists the letters you accept. A letter followed by a colon takes a value:
'd:v'means-dneeds an argument and-vdoes not. OPTARGholds the value of an option that takes one.OPTINDis the index of the next argument to be processed. It starts at 1.
A tiny script makes the mechanics visible:
#!/bin/bash
while getopts 'd:v' opt; do
printf 'opt=%s OPTARG=%s OPTIND=%s\n' "$opt" "${OPTARG-<unset>}" "$OPTIND"
done$ ./loud -v -d /tmp
opt=v OPTARG=<unset> OPTIND=2
opt=d OPTARG=/tmp OPTIND=4OPTIND jumps from 2 to 4 because -d used two words. getopts stops at the first argument that does not start with -, or at --. Everything from OPTIND onwards is your positional arguments, which is why every getopts loop is followed by:
shift $(( OPTIND - 1 ))After that, $1 is the first non-option argument and "$@" is the full list of them, the file names or whatever else the script works on.
Silent mode: the leading colon
Without any special setup, getopts prints its own error messages and sets the option variable to ?:
$ ./loud -x
./loud: illegal option -- x
opt=? OPTARG=<unset> OPTIND=2
$ ./loud -d
./loud: option requires an argument -- d
opt=? OPTARG=<unset> OPTIND=2That works, but you cannot tell the two errors apart in your case and you cannot change the wording. If the option string starts with a colon, getopts prints nothing and reports errors to you instead:
- Unknown option: the variable is set to
?andOPTARGholds the offending letter. - Missing value: the variable is set to
:andOPTARGholds the option that needed it.
I always use silent mode. It lets the script print one consistent message and exit with status 2, the conventional code for "wrong usage".
A complete example
Here is the skeleton I copy into new scripts. It is a fake backup command that parses its options and prints the result, so you can see each case working. It has a required option (-d), an option with a default (-k), two flags, a counting flag (-vv for more verbose) and a usage function.
#!/bin/bash
# backup -- getopts demo: parse options, print the result
usage() {
cat <<USAGE
Usage: ${0##*/} [-v] [-n] [-k DAYS] -d DEST FILE...
-d DEST destination directory (required)
-k DAYS keep backups for DAYS days (default: 7)
-n dry run: show what would be copied
-v verbose
-h show this help
USAGE
}
die() {
printf '%s: %s\n' "${0##*/}" "$1" >&2
printf "Try '%s -h' for help.\n" "${0##*/}" >&2
exit 2
}
dest=
keep=7
dry_run=0
verbose=0
while getopts ':d:k:nvh' opt; do
case $opt in
d) dest=$OPTARG ;;
k) keep=$OPTARG ;;
n) dry_run=1 ;;
v) verbose=$(( verbose + 1 )) ;;
h) usage; exit 0 ;;
:) die "-$OPTARG needs a value" ;;
\?) die "unknown option -$OPTARG" ;;
esac
done
shift $(( OPTIND - 1 ))
[[ -n $dest ]] || die "-d DEST is required"
[[ $keep =~ ^[0-9]+$ ]] || die "-k expects a number, got '$keep'"
(( $# > 0 )) || die "no files given"
printf 'dest=%s keep=%s dry_run=%s verbose=%s\n' \
"$dest" "$keep" "$dry_run" "$verbose"
for f in "$@"; do
printf 'file: %s\n' "$f"
doneA few details matter here. The \? pattern is escaped because an unescaped ? in case is a glob that matches any single character, which would match every option. The : and \? branches only ever run because of the leading colon in ':d:k:nvh'. Help goes to standard output with status 0. Errors go to standard error with status 2.
Test runs
$ ./backup -d /srv/backup notes.txt 'my report.odt'
dest=/srv/backup keep=7 dry_run=0 verbose=0
file: notes.txt
file: my report.odt
$ ./backup -vv -n -k 30 -d/srv/backup a.txt
dest=/srv/backup keep=30 dry_run=1 verbose=2
file: a.txt
$ ./backup -nvd /srv/backup a.txt
dest=/srv/backup keep=7 dry_run=1 verbose=1
file: a.txt
$ ./backup -d /srv/backup -- -weird-name.txt
dest=/srv/backup keep=7 dry_run=0 verbose=0
file: -weird-name.txtGrouped flags (-nvd), a value glued to its option (-d/srv/backup) and -- to end option parsing all work without any extra code. That is the main reason to use getopts rather than a loop of your own. The error paths:
$ ./backup -d
backup: -d needs a value
Try 'backup -h' for help.
$ ./backup -x -d /srv/backup a.txt
backup: unknown option -x
Try 'backup -h' for help.
$ ./backup -k ten -d /srv/backup a.txt
backup: -k expects a number, got 'ten'
Try 'backup -h' for help.
$ ./backup -d /srv/backup; echo "exit=$?"
backup: no files given
Try 'backup -h' for help.
exit=2Required options and validation
getopts has no notion of a required option. The colon in d: only means "if -d is given, it must have a value". You must check after the loop that -d was given at all. The [[ -n $dest ]] line does this. The same goes for validating values: getopts accepts -k ten without complaint, so the script checks it with a regex, in the same way as is_num().
Two pitfalls are worth knowing:
$ ./backup -d -n a.txt
dest=-n keep=7 dry_run=0 verbose=0
file: a.txt
$ ./backup a.txt -d /srv/backup
backup: -d DEST is required
Try 'backup -h' for help.The first is not a bug: -d takes the next word as its value, whatever it looks like. The second surprises people used to GNU tools, which let you put options after file names. getopts stops at the first non-option, so -d here is treated as a file name and never parsed. That is the POSIX behaviour, and I would not try to work around it.
getopts inside a function
OPTIND is a global. If you call a function that uses getopts twice, the second call starts where the first one stopped and sees nothing. Declare local OPTIND=1 (along with the option variable) at the top of the function:
parse() {
local opt OPTIND=1
while getopts 'q' opt; do
printf 'got -%s\n' "$opt"
done
}Without that local, the second call to parse -q printed nothing in my test.
Why there are no long options
getopts is specified by POSIX, and POSIX utilities only have single-letter options. Long options like --dry-run are a GNU convention, so the builtin never added them. The external getopt program from util-linux does support long options, but the BSD getopt on macOS does not. Also, traditional getopt implementations break arguments that contain spaces. If you want --long options and portability, a manual loop is the realistic answer.
A simple manual loop
#!/bin/bash
# backup-long -- manual parser for short and --long options
die() {
printf '%s: %s\n' "${0##*/}" "$1" >&2
exit 2
}
dest=
keep=7
dry_run=0
while (( $# > 0 )); do
case $1 in
-d|--dest)
(( $# >= 2 )) || die "$1 needs a value"
dest=$2; shift 2 ;;
--dest=*)
dest=${1#*=}; shift ;;
-k|--keep)
(( $# >= 2 )) || die "$1 needs a value"
keep=$2; shift 2 ;;
--keep=*)
keep=${1#*=}; shift ;;
-n|--dry-run)
dry_run=1; shift ;;
-h|--help)
printf 'Usage: %s [-n] [-k DAYS] -d DEST FILE...\n' "${0##*/}"
exit 0 ;;
--)
shift; break ;;
-*)
die "unknown option $1" ;;
*)
break ;;
esac
done
[[ -n $dest ]] || die "--dest is required"
printf 'dest=%s keep=%s dry_run=%s files=%s\n' \
"$dest" "$keep" "$dry_run" "$*"The loop looks at $1, handles it, and shifts it off. Options with values check that a value exists before reading $2, so --keep at the end of the line gives a clear error instead of an empty value. Both --keep 30 and --keep=30 work, thanks to the ${1#*=} expansion that strips everything up to the first =.
$ ./backup-long --dest /srv/backup --keep=30 a.txt b.txt
dest=/srv/backup keep=30 dry_run=0 files=a.txt b.txt
$ ./backup-long -n -d /srv/backup a.txt
dest=/srv/backup keep=7 dry_run=1 files=a.txt
$ ./backup-long --dest=/srv/backup -- --odd-name
dest=/srv/backup keep=7 dry_run=0 files=--odd-name
$ ./backup-long --keep
backup-long: --keep needs a value
$ ./backup-long -nd /srv/backup a.txt
backup-long: unknown option -ndThe last run shows what you give up: grouped short flags no longer work, because the loop compares whole words. For most of my scripts that loss is acceptable. If a script needs both grouped short flags and long options, I usually take that as a sign that it has become too large for Bash.
The getopts entry in the Bash manual is short and worth a read. The code above behaves the same in Bash 3.2 on macOS and in Bash 5, although the wording of the built-in error messages in non-silent mode can vary.