| |

Bash Scripting 24 🐧 Parsing Options — Building robust CLI flags using getopts

⏱️ Estimated read: 11 min | |

A script that accepts flags must parse them before it does anything else. The naive approach—checking $1 against a list of strings and shifting—works for one or two flags but collapses under the weight of options that take arguments, combined short flags (-ab), and the need to distinguish “missing argument” from “invalid option.” The getopts builtin is Bash’s answer to this problem. It processes positional parameters in a loop, assigns each option to a named variable, stores option arguments in OPTARG, and advances an internal pointer called OPTIND so the script knows where the options end and the positional arguments begin .

This chapter covers getopts as a complete option-parsing mechanism: the optstring syntax that declares which options exist and which take arguments, the while getopts loop and case dispatch pattern, the role of OPTARG and OPTIND, and the silent-error mode that replaces default error messages with script-controlled handling. It also covers the shift $((OPTIND - 1)) idiom that discards parsed options and leaves the remaining arguments in $@.

Key point: getopts parses options in a loop and leaves positional arguments behind. The loop ends when getopts returns non-zero. Then shift $((OPTIND - 1)) removes the parsed options from $@, leaving only the non-option arguments for the rest of the script.


Why getopts works this way

The optstring contract problem. getopts needs to know which letters are valid options and which of those require an argument. The optstring is that contract: a string of characters where each letter is a recognized option, and a colon after a letter means that option takes an argument . The string "ab:c" declares three options: -a (no argument), -b (requires an argument), and -c (no argument). An option letter not in the string is invalid, and getopts reports it.

The loop-and-state problem. Unlike an external getopt command that processes the entire argument list at once, getopts is called repeatedly. Each invocation processes one option, assigns it to the variable named in the second argument, and updates OPTIND. The loop continues until getopts returns non-zero, which happens when it encounters a non-option argument or runs out of arguments . This design lets the script react to each option as it is found, using a case statement.

The missing-argument problem. When an option requires an argument and none is supplied, getopts has two behaviors depending on the first character of optstring. If optstring does not start with a colon, getopts prints its own error message, sets the option variable to ?, and unsets OPTARG. If optstring starts with a colon, getopts suppresses the message and sets the option variable to : for a missing argument, with OPTARG holding the option letter that was missing its argument . The silent mode exists so that scripts can produce their own usage messages instead of relying on getopts‘s terse diagnostics.

The option-argument-concatenation problem. getopts accepts both -b value (separate argument) and -bvalue (concatenated) as valid forms for an option that takes an argument . This is standard Unix behavior, and getopts implements it without configuration. The script does not need to handle the two forms separately; OPTARG contains the value either way.


a. The optstring and the getopts loop

The optstring declares the option letters. A colon after a letter means the option takes an argument. A leading colon enables silent error reporting.

# Options: -v (verbose), -o (output file, takes argument), -h (help)
while getopts "vo:h" opt; do
    case "$opt" in
        v) verbose=1 ;;
        o) output="$OPTARG" ;;
        h) usage; exit 0 ;;
        \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
    esac
done

The loop variable (opt in this example) receives each option letter as getopts processes it. The case statement dispatches on that letter. The \? pattern catches invalid options. If the optstring had a leading colon (":vo:h"), the invalid option would set opt to : for a missing argument, or ? for an invalid option, with OPTARG holding the offending character.

b. OPTARG and OPTIND

OPTARG holds the argument of the current option when that option requires one. It is set by getopts before the case statement runs. If the option does not take an argument, OPTARG is unset .

OPTIND is the index of the next argument to be processed. It starts at 1 when the shell or script is invoked. After the getopts loop finishes, OPTIND points to the first non-option argument. The standard idiom for discarding the parsed options and keeping the positional arguments is:

shift $((OPTIND - 1))

After this shift, $1 is the first positional argument, not the first option. Without the shift, the script would still see the options in $@ and would need to skip past them manually .

c. Silent error handling

By default, getopts prints its own error messages to standard error. A leading colon in optstring disables those messages and changes the error-reporting behavior so the script can handle errors itself .

while getopts ":vo:h" opt; do
    case "$opt" in
        v) verbose=1 ;;
        o) output="$OPTARG" ;;
        h) usage; exit 0 ;;
        :) echo "Option -$OPTARG requires an argument" >&2; exit 1 ;;
        \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
    esac
done

The : case is reached when an option that requires an argument is missing it. OPTARG holds the option letter. The \? case is reached for an invalid option, with OPTARG holding the invalid character. This pattern gives the script full control over usage messages, which is preferable for user-facing tools.


Complete Example Session

# ============================================
# PART 1: MINIMAL GETOPTS LOOP
# ============================================
#!/bin/bash

verbose=0

while getopts "v" opt; do
    case "$opt" in
        v) verbose=1 ;;
    esac
done

# -v sets verbose to 1.
# No argument-taking options yet.
# ============================================
# PART 2: AN OPTION THAT TAKES AN ARGUMENT
# ============================================
#!/bin/bash

output=""

while getopts "o:" opt; do
    case "$opt" in
        o) output="$OPTARG" ;;
    esac
done

# -o requires an argument.
# -o file.txt and -ofile.txt both set output to file.txt.
# ============================================
# PART 3: COMBINED OPTIONS
# ============================================
#!/bin/bash

verbose=0
force=0

while getopts "vf" opt; do
    case "$opt" in
        v) verbose=1 ;;
        f) force=1 ;;
    esac
done

# -vf sets both verbose and force.
# getopts processes each letter separately.
# ============================================
# PART 4: THE USAGE FUNCTION
# ============================================
usage() {
    echo "Usage: $0 [-v] [-f] [-o output] input" >&2
    echo "  -v  verbose mode" >&2
    echo "  -f  force overwrite" >&2
    echo "  -o  output file" >&2
    exit 1
}

# Called when an invalid option or missing argument occurs.
# ============================================
# PART 5: SILENT ERROR MODE
# ============================================
#!/bin/bash

while getopts ":vfo:" opt; do
    case "$opt" in
        v) verbose=1 ;;
        f) force=1 ;;
        o) output="$OPTARG" ;;
        :) echo "Missing argument for -$OPTARG" >&2; usage ;;
        \?) echo "Invalid option: -$OPTARG" >&2; usage ;;
    esac
done

# Leading colon suppresses default error messages.
# Script prints its own.
# ============================================
# PART 6: SHIFTING PAST THE OPTIONS
# ============================================
# After the loop, OPTIND points to the first non-option.
shift $((OPTIND - 1))

# Now $1 is the first positional argument.
# If no positional arguments remain, $# is 0.
# ============================================
# PART 7: HANDLING POSITIONAL ARGUMENTS
# ============================================
if [[ $# -eq 0 ]]; then
    echo "Error: input file required" >&2
    usage
fi

input="$1"

# The script now has both parsed options and positional arguments.
# ============================================
# PART 8: COMPLETE SCRIPT
# ============================================
#!/bin/bash
set -euo pipefail

verbose=0
force=0
output=""

usage() {
    echo "Usage: $0 [-v] [-f] [-o output] input" >&2
    exit 1
}

while getopts ":vfo:" opt; do
    case "$opt" in
        v) verbose=1 ;;
        f) force=1 ;;
        o) output="$OPTARG" ;;
        :) echo "Missing argument for -$OPTARG" >&2; usage ;;
        \?) echo "Invalid option: -$OPTARG" >&2; usage ;;
    esac
done

shift $((OPTIND - 1))

[[ $# -eq 0 ]] && usage
input="$1"

echo "verbose=$verbose force=$force output=$output input=$input"
# ============================================
# PART 9: TESTING THE SCRIPT
# ============================================
# ./script.sh -v -o out.txt data.csv
# verbose=1 force=0 output=out.txt input=data.csv

# ./script.sh -vfo out.txt data.csv
# verbose=1 force=1 output=out.txt input=data.csv

# ./script.sh -x data.csv
# Invalid option: -x
# Usage: ...
# ============================================
# PART 10: THE OPTIND RESET
# ============================================
# OPTIND is global to the script.
# If you call getopts twice in one script, reset OPTIND first:
OPTIND=1

# Otherwise the second getopts loop starts where the first ended.

The ten parts covered the minimal loop, argument-taking options, combined options, the usage function, silent error mode, the shift idiom, positional argument handling, the complete script, testing, and the OPTIND reset.


Quick Reference

getopts syntax

ElementMeaning
getopts "vo:h" optParse options, assign each to $opt
v in optstring-v is valid, no argument
o: in optstring-o is valid, requires argument
Leading :Silent error mode
$OPTARGArgument of the current option
$OPTINDIndex of next argument

Error cases

ConditionDefault behaviorSilent mode (:)
Invalid optionopt = ?, message printedopt = ?, OPTARG = char
Missing argumentopt = ?, message printedopt = :, OPTARG = char

The pattern

StepCode
Loopwhile getopts ":..." opt; do
Dispatchcase "$opt" in ... esac
Discard optionsshift $((OPTIND - 1))
Access positional$1, $2, $@

Best Practices

✅ Do This:

# Use a leading colon for script-controlled error messages
while getopts ":vfo:" opt; do                              # ✅
# Shift past options after the loop
shift $((OPTIND - 1))                                      # ✅
# Handle missing arguments explicitly
:) echo "Missing argument for -$OPTARG" >&2; exit 1 ;;     # ✅
# Provide a usage function
usage() { echo "Usage: $0 [-v] input" >&2; exit 1; }       # ✅

❌ Don’t Do This:

# Forget to shift past options
# $1 still contains an option after the loop                # ❌
# Use getopt (external) for short options
# getopts is builtin and handles "$@" natively               # ❌
# Assume OPTARG is set for options without arguments
# It is unset for flags                                       # ❌
# Call getopts twice without resetting OPTIND
OPTIND=1  # required before a second loop                    # ❌ (missing)

Common Pitfalls

PitfallWhy It HappensFix
Positional args still contain optionsshift $((OPTIND - 1)) missingAdd the shift after the loop
Script prints getopts‘s error messageNo leading colon in optstringAdd : as first character
OPTARG empty for a flagFlags do not take argumentsDo not use OPTARG for flags
Invalid option not caughtcase missing \? patternAdd \?) ... ;;
Missing argument not caughtcase missing : patternAdd :) ... ;;
Second getopts loop skips optionsOPTIND not resetSet OPTIND=1 before the loop

Real-World Examples

1. Flag only

while getopts "v" opt; do case "$opt" in v) verbose=1 ;; esac; done

2. Option with argument

while getopts "o:" opt; do case "$opt" in o) output="$OPTARG" ;; esac; done

3. Multiple flags

while getopts "vfn" opt; do case "$opt" in v) v=1;; f) f=1;; n) n=1;; esac; done

4. Silent mode

while getopts ":vfo:" opt; do ...

5. Missing argument handler

:) echo "Missing argument for -$OPTARG" >&2; exit 1 ;;

6. Invalid option handler

\?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;

7. Shift past options

shift $((OPTIND - 1))

8. Check remaining arguments

[[ $# -eq 0 ]] && { echo "No input file" >&2; exit 1; }

9. Usage function

usage() { echo "Usage: $0 [-v] [-o output] input" >&2; exit 1; }

10. Reset OPTIND for a second parse

OPTIND=1

Visual

The getopts loop and shift

┌─────────────────────────────────────────────────────────────┐
│  GETOPTS PROCESSING                                          │
│                                                              │
│  Command line: ./script.sh -v -o out.txt data.csv           │
│                                                              │
│  $@: [-v] [-o] [out.txt] [data.csv]                         │
│                                                              │
│  Loop 1: getopts → opt=v, OPTIND=2                          │
│  Loop 2: getopts → opt=o, OPTARG=out.txt, OPTIND=4          │
│  Loop 3: getopts returns 1 (data.csv is not an option)      │
│                                                              │
│  After loop: OPTIND=4                                        │
│  shift $((4 - 1)) = shift 3                                  │
│                                                              │
│  $@: [data.csv]                                              │
│  $1 = data.csv                                               │
└─────────────────────────────────────────────────────────────┘

optstring parsing

┌─────────────────────────────────────────────────────────────┐
│  OPTSTRING: "vfo:"                                           │
│                                                              │
│  v    valid option, no argument                              │
│  f    valid option, no argument                              │
│  o    valid option, REQUIRES argument                        │
│  :    (after o) marks the requirement                        │
│                                                              │
│  OPTSTRING: ":vfo:"                                          │
│  :    (leading) silent error mode                            │
│  v    valid option, no argument                              │
│  f    valid option, no argument                              │
│  o:   valid option, requires argument                        │
└─────────────────────────────────────────────────────────────┘

Error handling flow

┌─────────────────────────────────────────────────────────────┐
│  SILENT MODE ERROR CASES                                     │
│                                                              │
│  optstring = ":vo:"                                          │
│                                                              │
│  Command: ./script.sh -x                                     │
│    getopts: opt = ?, OPTARG = x                              │
│    case \?) → "Invalid option: -x"                           │
│                                                              │
│  Command: ./script.sh -o                                     │
│    getopts: opt = :, OPTARG = o                              │
│    case :) → "Missing argument for -o"                       │
│                                                              │
│  The script controls every error message.                    │
└─────────────────────────────────────────────────────────────┘

OPTIND and positional arguments

┌─────────────────────────────────────────────────────────────┐
│  BEFORE AND AFTER SHIFT                                      │
│                                                              │
│  Before loop:                                                │
│    $@ = -v -o out.txt data.csv                               │
│    OPTIND = 1                                                │
│                                                              │
│  After loop:                                                 │
│    $@ = -v -o out.txt data.csv (unchanged)                   │
│    OPTIND = 4 (points to data.csv)                           │
│                                                              │
│  After shift $((OPTIND - 1)):                                │
│    $@ = data.csv                                             │
│    $1 = data.csv                                             │
│                                                              │
│  The options are gone from $@.                               │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Builtingetopts
Syntaxgetopts optstring name [args]
optstringOption letters, colon after letter means argument
Leading colonSilent error mode
Option variableNamed in second argument (opt, name)
Argument variable$OPTARG
Index variable$OPTIND
Discard optionsshift $((OPTIND - 1))
Reset for reuseOPTIND=1
Missing argument (silent)opt = :, OPTARG = option letter
Invalid option (silent)opt = ?, OPTARG = invalid char

Key takeaways:

  • getopts is a builtin, not an external command. It parses positional parameters in a loop and is available in every Bash script without dependencies .
  • The optstring is the option contract. Letters declare valid options; a trailing colon means the option requires an argument. A leading colon enables silent mode.
  • OPTARG holds the argument; OPTIND tracks position. After the loop, OPTIND points to the first non-option argument. shift $((OPTIND - 1)) removes the parsed options from $@.
  • Silent mode gives the script control over errors. With a leading colon, getopts does not print its own messages. The : case handles missing arguments; the \? case handles invalid options.
  • Both -o value and -ovalue work. getopts accepts concatenated and separate forms for option arguments without additional code .
  • OPTIND is global and must be reset for a second parse. Calling getopts twice in one script without OPTIND=1 produces unspecified results .

Remember: getopts turns a list of positional parameters into a sequence of option events. The loop processes each event, the case statement decides what to do, and the shift after the loop leaves only the arguments that are not options. The leading colon and the : and \? cases are what separate a robust script from one that prints cryptic errors and exits without explanation. Use getopts for short options; use an external getopt only when long options are required.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!