Skip to content

getopts — Shell Argument Parser


Every script in this course so far has read its arguments positionally — $1 is always this, $2 is always that. Real command-line tools don’t work that way: flags can appear in any order, some take a value and some don’t, and users expect -v, -f report.txt, and -vf report.txt to all behave sensibly. Hand-rolling that with if/elif chains against $1 gets unwieldy fast. getopts is Bash’s built-in answer to exactly this problem.

The Problem With Manual $1/$2 Parsing

Here’s what parsing flags by hand tends to look like:

while [[ "$#" -gt 0 ]]; do
    case "$1" in
        -v)
            verbose=true
            shift
            ;;
        -f)
            filename="$2"
            shift 2
            ;;
        *)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
    esac
done

This works, but it’s already showing cracks: every option with a value needs its own careful shift 2, nothing handles combined short flags like -vf, and the case list only grows more fragile as more options are added. getopts handles all of this for you, with a fraction of the code.

getopts Basics

while getopts "vf:" opt; do
    case "$opt" in
        v) verbose=true ;;
        f) filename="$OPTARG" ;;
        *) echo "Unknown option" >&2 ;;
    esac
done

The option string, "vf:", defines what’s valid: v is a plain flag (no value expected), and f: — note the trailing colon — means -f requires a value. getopts is called repeatedly inside a while loop, returning one option per call, until there are no more to process.

./tool.sh -v -f report.txt
./tool.sh -vf report.txt

Both invocations above work identically — getopts correctly handles a combined short-flag cluster (-vf) exactly as well as separate flags, something the manual $1/$2 version above couldn’t do without significant extra logic.

OPTARG — Capturing An Option’s Value

For any option defined with a trailing : in the option string, getopts places that option’s value into OPTARG automatically — exactly as used above with filename="$OPTARG".

OPTIND — Tracking Position Across Multiple getopts Calls

OPTIND is the index getopts is currently working through in the argument list — it advances automatically as options are consumed. You rarely need to read it directly, but it matters a great deal once you’re done parsing: after the loop, shift $((OPTIND - 1)) removes every argument getopts already consumed, leaving only genuine positional arguments (anything after the flags) in $1, $2, and so on.

while getopts "vf:" opt; do
    case "$opt" in
        v) verbose=true ;;
        f) filename="$OPTARG" ;;
    esac
done
shift $((OPTIND - 1))

echo "Remaining positional arguments: $#"
echo "First one: $1"
./tool.sh -v -f report.txt input.csv
Remaining positional arguments: 1
First one: input.csv

-v and -f report.txt were both consumed by the getopts loop; shift $((OPTIND - 1)) cleared them out, leaving input.csv sitting cleanly at $1 for the rest of the script to use.

Handling Invalid Options

By default, an unrecognized option makes getopts print its own error message and set opt to ?. Prefixing the option string with a leading : switches to silent mode, letting you write your own error messages instead — opt becomes ? for an unknown option, and : specifically for a known option that’s missing its required argument:

while getopts ":vf:" opt; do
    case "$opt" in
        v) verbose=true ;;
        f) filename="$OPTARG" ;;
        \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
        :) echo "Option -$OPTARG requires an argument" >&2; exit 1 ;;
    esac
done
./tool.sh -x
Invalid option: -x
./tool.sh -f
Option -f requires an argument

Both messages are yours to write, worded however makes sense for your specific script, rather than getopts’s generic built-in phrasing.

-- — Ending Option Parsing

By convention, -- signals “everything after this is a positional argument, even if it starts with a dash.” getopts already understands this automatically — it stops consuming options at the first -- it encounters, and shift $((OPTIND - 1)) correctly leaves everything after it (including the -- itself) ready for further handling:

./tool.sh -v -- -not-a-flag.txt

Here, -not-a-flag.txt is treated as a genuine filename argument, not an attempt at an unrecognized option, because it comes after --.

Shifting Past Parsed Options

To repeat the pattern shown above, since it’s easy to forget: always follow a getopts loop with

shift $((OPTIND - 1))

Without it, $1 still points at whatever came before getopts started consuming arguments — not at the first genuine positional argument your script actually needs to work with next.

The Limitation: No Positional Argument Checks

Handling Invalid Options is great but still positional arguments gets unchecked and passed:

./tool.sh foo -f file.txt

No failure on the extra foo. You should explicitly catch this after getopts:


shift $((OPTIND - 1))

if [[ $# -ne 0 ]]; then
    echo "Unexpected argument: $1" >&2
    exit 1
fi

So more complete version of handling invalid options would be:

while getopts ":vf:" opt; do
    case "$opt" in
        v) verbose=true ;;
        f) filename="$OPTARG" ;;
        \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
        :) echo "Option -$OPTARG requires an argument" >&2; exit 1 ;;
    esac
done


shift $((OPTIND - 1))

if [[ $# -ne 0 ]]; then
    echo "Unexpected argument: $1" >&2
    exit 1
fi

Now foo is catched as Unexpected argument:

./tool.sh foo -f file.txt
Unexpected argument: foo

The Limitation: No Long Options

getopts only understands single-character options — -v, -f value. It has no built-in support for GNU-style long options like --verbose or --file=report.txt. If a script genuinely needs long-option support, that requires either writing manual parsing logic for those specific flags, or reaching for a separate external tool built for the purpose — not something getopts itself can do. Plenty of real, professional Bash tools stick to short options alone specifically to keep getopts sufficient; it’s a reasonable design constraint to accept rather than work around, unless long options are a genuine requirement.

Best Practices

  • Prefer getopts over manual $1/$2 parsing for any script accepting more than one or two flags — it correctly handles combined flags, required values, and --, all of which manual parsing has to painstakingly reimplement.
  • Always follow the getopts loop with shift $((OPTIND - 1)) — treat this as a fixed, required pattern, not an optional cleanup step.
  • Use the leading : silent-error mode for anything beyond a quick internal script, so your error messages actually explain what went wrong in terms specific to your tool.
  • Accept getopts’s single-character-only limitation rather than fighting it — reach for a different approach only when long options are a genuine, specific requirement.

Shell-Safety Considerations

OPTIND doesn’t reset itself automatically between separate invocations of getopts in the same shell session — and that becomes a real, silent bug the moment getopts is used inside a function that might be called more than once.

Naive (broken) version:

parse_args() {
    while getopts "v" opt; do
        case "$opt" in
            v) echo "verbose flag detected" ;;
        esac
    done
}

parse_args -v
parse_args -v
verbose flag detected

Only one line of output for two identical calls. The first call to parse_args advances OPTIND past its single argument as it processes -v. Because OPTIND is a regular shell variable and nothing reset it, the second call to parse_args starts getopts scanning from wherever the first call left off — past the only argument that second call actually received — and silently finds nothing to process.

Corrected version — reset OPTIND locally at the start of the function:

parse_args() {
    local OPTIND=1
    while getopts "v" opt; do
        case "$opt" in
            v) echo "verbose flag detected" ;;
        esac
    done
}

parse_args -v
parse_args -v
verbose flag detected
verbose flag detected

local OPTIND=1 gives each call to parse_args its own fresh copy of OPTIND, starting from the beginning every time, exactly as local protected against the general variable-leak problem back in the Functions chapter. Any function that uses getopts and might be called more than once in the same script needs this — without it, only the first call ever parses its arguments correctly.

Hands-On: Parsing Flags Properly

1. Set up a working directory.

mkdir -p ~/shell-course/ch17
cd ~/shell-course/ch17

2. Build a script using getopts with a flag and a required-value option.

cat > tool.sh << 'EOF'
#!/usr/bin/env bash

verbose=false
filename=""

while getopts ":vf:" opt; do
    case "$opt" in
        v) verbose=true ;;
        f) filename="$OPTARG" ;;
        \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
        :) echo "Option -$OPTARG requires an argument" >&2; exit 1 ;;
    esac
done
shift $((OPTIND - 1))

echo "verbose=$verbose"
echo "filename=$filename"
echo "Remaining args: $#"
echo "First remaining: $1"
EOF
chmod +x tool.sh

3. Test it several ways.

./tool.sh -v -f report.txt input.csv
./tool.sh -vf report.txt input.csv
./tool.sh -x
./tool.sh -f

4. Test -- explicitly.

./tool.sh -v -- -not-a-flag.txt

5. Reproduce the OPTIND reuse bug across function calls, then fix it.

cat > optind-demo.sh << 'EOF'
#!/usr/bin/env bash

echo "--- broken ---"
parse_broken() {
    while getopts "v" opt; do
        case "$opt" in
            v) echo "verbose flag detected" ;;
        esac
    done
}
parse_broken -v
parse_broken -v

echo "--- fixed ---"
parse_fixed() {
    local OPTIND=1
    while getopts "v" opt; do
        case "$opt" in
            v) echo "verbose flag detected" ;;
        esac
    done
}
parse_fixed -v
parse_fixed -v
EOF
chmod +x optind-demo.sh
./optind-demo.sh

6. Clean up.

cd ~
rm -rf ~/shell-course/ch17

You’ve now replaced manual $1/$2 flag parsing with getopts, handled combined short flags, required option values, custom error messages, and --, and confirmed exactly why local OPTIND=1 is required in any function that parses options more than once. The next chapter covers set and shopt — including set -euo pipefail, dissected piece by piece.

Last updated on