Bash and Linux

Parse Flags with getopts

mediumBash scripts and automation

Problem statement

Give a script proper options, -f FILE, -n COUNT and -v, with getopts, and handle unknown options, missing values and leftover arguments. Run it with several argument sets and print what it understood. Options make scripts flexible without depending on argument order, and getopts is built into every bash.

The script saves the tool as backup.sh and runs it with these arguments:

runs

TEXT
-f db.sql -n 3
-v -f db.sql extra1 extra2
-vf db.sql
-n 2
-x
-f

For each run, print the command, what it parsed (or its error), and its exit code.

Expected output:

Bash
$ backup.sh -f db.sql -n 3
file=db.sql count=3 verbose=false rest=[]
-> exit 0
$ backup.sh -v -f db.sql extra1 extra2
file=db.sql count=1 verbose=true rest=[extra1 extra2]
-> exit 0
$ backup.sh -vf db.sql
file=db.sql count=1 verbose=true rest=[]
-> exit 0
$ backup.sh -n 2
error: -f FILE is required
usage: backup.sh -f FILE [-n COUNT] [-v] [NAME...]
-> exit 2
$ backup.sh -x
error: unknown option -x
usage: backup.sh -f FILE [-n COUNT] [-v] [NAME...]
-> exit 2
$ backup.sh -f
error: -f needs a value
usage: backup.sh -f FILE [-n COUNT] [-v] [NAME...]
-> exit 2

Hints

Hint 1: while getopts ":f:n:v" opt; do case $opt in ... esac; done reads one option per loop. A letter followed by : takes a value, which arrives in $OPTARG.

Approach

Optimal: getopts with case

Covers: options vs positional arguments, getopts option strings, OPTARG, OPTIND, shift $((OPTIND - 1)), quiet error mode with a leading :, combined flags like -vf, required options, why there are no long options.

Strict mode, used on every page in this section. The second line, set -euo pipefail, makes bash stop on mistakes instead of carrying on:

Option Means
-e exit as soon as a command fails (with some exceptions, see Handle Command Failures)
-u treat an unset variable as an error, instead of silently using empty text
-o pipefail a pipeline fails if any command in it fails, not just the last one

Put it right after the shebang line #!/usr/bin/env bash in every script you write.

Options and arguments. Options start with - and can come in any order: -n 3 -f db.sql means the same as -f db.sql -n 3. Some are switches (-v on or off), some take a value (-f db.sql). Whatever is left after the options are the positional arguments.

The option string. getopts ":f:n:v" opt describes the options:

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB S["":f:n:v""]:::purple S --> Q[":
quiet errors"]:::gray S --> F["f:
-f takes a value"]:::blue S --> N["n:
-n takes a value"]:::blue S --> V["v
-v is a switch"]:::green classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px
Part Means
leading : quiet mode: you print the errors yourself
f: -f takes a value
n: -n takes a value
v -v is a switch

One option per loop. Each time round, getopts puts the next option letter in opt and its value in OPTARG, and returns false when the options run out. case handles each letter:

opt When OPTARG
f, n, v a known option the value, for f and n
? an unknown option like -x the letter x
: a value is missing, as in a bare -f the letter f

It also understands combined switches: -vf db.sql is -v plus -f db.sql.

Getting the rest. OPTIND is the number of the next argument to look at. When the loop ends, shift $((OPTIND - 1)) removes all the options, so "$@" holds just the leftover arguments, like extra1 extra2.

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart LR subgraph A["-v -f db.sql extra1 extra2"] direction TB A1["-v -f db.sql
read by getopts"]:::blue ~~~ A2["extra1 extra2
left in $@ after shift"]:::green end classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style A fill:transparent,stroke:#7c3aed,stroke-width:2px

Required options. getopts does not know an option is required. Check after the loop, [[ -n $file ]] || usage, which is how -n 2 alone fails.

Walking through the code. The # Setup: lines write the tool to backup.sh, so skip past the setup part; the tool itself is the solution.

  1. Defaults are set first: count=1, verbose=false.
  2. The while getopts loop fills them in; unknown options and missing values print an error and call usage, which exits 2.
  3. After shift, the tool checks -f was given, then prints what it parsed.
  4. try runs each argument set with 2>&1 and prints the exit code.

Edge cases. getopts stops at the first argument that is not an option, so backup.sh extra -v leaves -v unparsed; options must come first. -- ends the options explicitly, so a value like -weird can follow as an argument. The value of -n is still just text; check it is a number as on the Arguments and Exit Codes page.

#!/usr/bin/env bash
set -euo pipefail

# Setup: write the tool in a fresh temporary folder
cd "$(mktemp -d)"
cat > backup.sh << 'TOOL'
#!/usr/bin/env bash
set -euo pipefail

usage() { echo "usage: ${0##*/} -f FILE [-n COUNT] [-v] [NAME...]" >&2; exit 2; }

file="" count=1 verbose=false
while getopts ":f:n:v" opt; do
  case $opt in
    f)  file=$OPTARG ;;
    n)  count=$OPTARG ;;
    v)  verbose=true ;;
    :)  echo "error: -$OPTARG needs a value" >&2; usage ;;
    \?) echo "error: unknown option -$OPTARG" >&2; usage ;;
  esac
done
shift $((OPTIND - 1))

[[ -n $file ]] || { echo "error: -f FILE is required" >&2; usage; }
echo "file=$file count=$count verbose=$verbose rest=[$*]"
TOOL

try() {
  echo "\$ backup.sh $*"
  bash ./backup.sh "$@" 2>&1 | sed 's/^/  /' && echo "  -> exit 0" || echo "  -> exit ${PIPESTATUS[0]}"
}
try -f db.sql -n 3
try -v -f db.sql extra1 extra2
try -vf db.sql
try -n 2
try -x
try -f

Interview follow-ups

  • Add --help, printing the usage to stdout and exiting 0.

    Check for it before the getopts loop, because getopts would see --help as the unknown option -: [[ ${1:-} == --help || ${1:-} == -h ]] && { echo "usage: ..."; exit 0; }. You can also add h to the option string and a h) case, so -h works anywhere among the options. Help asked for on purpose goes to stdout with exit 0; usage shown after a mistake goes to stderr with exit 2.

Frequently asked questions

No, bash's built-in getopts only knows single letters. You can handle a few long options by hand before the loop: a while over "$@" with case $1 in --file) file=$2; shift 2 ;; --help) ... covers most needs. The external getopt command (GNU, from util-linux) does support long options, but behaves differently on macOS and is easy to misuse. For scripts with many long options, consider whether Python's argparse would be clearer.

getopts (with an s) is a bash and POSIX built-in: always available, the same everywhere, single-letter options only. getopt is a separate program; the GNU version rearranges arguments and supports long options, while the old BSD version on macOS is limited and breaks on spaces. In portable scripts, use getopts. Only use getopt when you control the platform and really need long options.

getopts stops at the first argument that does not start with -, following the usual Unix rule that options come first. So in backup.sh notes.txt -v, -v is left in "$@" as a normal argument. Put options before arguments, or tell users so in the usage text. GNU tools often accept options anywhere because they rearrange arguments, which is why this surprises people.