Parsing Command-Line Options in Bash With getopts, the Professional Way
AI generated
$_
#!/
Bash · CLI Tools · Option Parsing · Scripting
Parsing Command-Line Options in Bash
the professional way, including long options

Anyone adding command-line options to a Bash script quickly ends up at getopts, the Bash built-in for short options. It is available everywhere, portable, and needs no external dependency, but it does not support long options like --verbose. Anyone who needs both combines getopts with a manual case loop and builds parameter parsing that feels like a real CLI tool.

16 min read getopts · OPTARG · OPTIND Bash 4.x · 5.x · POSIX compatible

1. Why professional parameter parsing matters in Bash scripts

A script that reads its arguments only through $1, $2 and $3 works fine right up until someone swaps the order, skips a flag, or needs one more option. Once a script grows past two or three fixed parameters, positional parsing turns confusing and error prone, because every new option shifts the position of every argument after it and breaks existing invocations.

Professional parameter parsing solves this by recognizing options through named flags instead of fixed positions. Users can supply options in any order, skip them, or combine them, without anything else in the script needing to change. getopts is the obvious tool for this, since it ships as a Bash built-in on any system with Bash, unlike the external getopt(1) command, whose capabilities vary by platform.

2. Basic getopts syntax: the option string, OPTARG and OPTIND

The basic form is a while loop: while getopts "vo:h" opt; do case $opt in ... esac; done. The option string "vo:h" defines three allowed short options: -v and -h are plain switches with no value, while -o requires an argument because of the trailing colon. On every iteration, getopts returns the next recognized option into the variable opt, until no options remain.

The value of an option that takes an argument lands automatically in OPTARG, while OPTIND internally tracks which position in $@ the next evaluation continues from. After the loop, OPTIND points at the position of the first non-option argument, which is why a closing shift $((OPTIND - 1)) is mandatory to expose the remaining positional parameters to the rest of the script.


#!/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] [-h]"; exit 0 ;;
    *) echo "Usage: $0 [-v] [-o file] [-h]" >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))

echo "verbose=$verbose output=$output remaining=$*"

3. Required arguments, plain switches and the leading colon

Whether an option needs an argument is controlled entirely by the colon in the option string: o: requires an argument, v without a colon is a plain switch. If a required option is missing its argument, getopts prints its own error to stderr by default and sets opt to a question mark, which the case branch then treats as an unknown or malformed option.

A leading colon at the very start of the whole option string, as in ":vo:h", switches on the so-called silent error mode: getopts then stops printing its own error message and instead sets opt to a colon when an argument is missing and to a question mark for an unknown option. That lets the script emit its own, consistently formatted error messages instead of relying on the generic text from getopts.


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

while getopts ":o:h" opt; do
  case "$opt" in
    o) output="$OPTARG" ;;
    h) echo "Usage: $0 [-o file] [-h]"; exit 0 ;;
    \?) echo "Error: unknown option -$OPTARG" >&2; exit 2 ;;
    :)  echo "Error: option -$OPTARG requires an argument" >&2; exit 2 ;;
  esac
done

4. The limits of getopts: no long options, no --option=value syntax

As useful as getopts is for short options, its limits are just as clear: it only recognizes single letters after a single dash. Something like --verbose or the combined form --output=file.txt is not understood by getopts, because the built-in simply was not designed to recognize multi-character option names. A call with --verbose gets treated by getopts as an unknown option, hitting the question-mark case.

The external getopt(1) command, particularly the GNU variant, does support long options including --option=value, but it is not equally available everywhere. macOS and many BSD systems ship only the older, functionally limited variant by default, which does not understand long options at all. Scripts meant to run on multiple platforms therefore often skip the external getopt entirely and resolve long options themselves in pure Bash.

5. Adding long options by hand: a case loop over the positional parameters

Instead of an external tool, a simple while [[ $# -gt 0 ]] loop that evaluates each positional parameter itself with case and advances with shift is enough. This technique is pure Bash, behaves identically everywhere, and allows arbitrarily complex option names, since there is no longer a fixed-letter option string, and every branch in the case block explicitly checks the full option name.

For the --output=file.txt form, the parameter expansion ${1#*=} helps, stripping everything up to the first equals sign and returning just the value. For options without an equals sign, such as --output file.txt with a space, the value instead has to be read from the next positional parameter, with an extra shift added to skip past it.


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

verbose=0
output=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --verbose) verbose=1; shift ;;
    --output=*) output="${1#*=}"; shift ;;
    --output) output="$2"; shift 2 ;;
    --help) echo "Usage: $0 [--verbose] [--output=file]"; exit 0 ;;
    --) shift; break ;;
    -*) echo "Unknown option: $1" >&2; exit 2 ;;
    *) break ;;
  esac
done

echo "verbose=$verbose output=$output remaining=$*"

6. Combining short and long options in the same script

Anyone who wants to offer both -v and --verbose is best off dropping getopts entirely and evaluating every option in a single manual case loop. Trying to run getopts for the short forms and a second loop for the long forms afterward quickly runs into ordering problems, because getopts stops immediately at the first unrecognized long argument and OPTIND no longer reliably points at the correct position.

The more robust solution is a single case branch that ties both the short and long form of each option to the same behavior, for example -v|--verbose). That keeps the logic in one place, makes new options easy to add, and leaves only a single spot where the short and long form could ever diverge in behavior.


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

verbose=0
output=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    -v|--verbose) verbose=1; shift ;;
    -o|--output)  output="$2"; shift 2 ;;
    --output=*)   output="${1#*=}"; shift ;;
    -h|--help)    echo "Usage: $0 [-v|--verbose] [-o|--output file]"; exit 0 ;;
    --) shift; break ;;
    -*) echo "Unknown option: $1" >&2; exit 2 ;;
    *) break ;;
  esac
done

7. Help text conventions: a usage() function, -h/--help and exit codes

A reusable Bash convention is a dedicated usage() function, defined once and called from several places in the script: on an explicit help request via -h or --help, as well as on every error case involving an unknown or missing option. That keeps the help text maintained in one place, instead of scattered across multiple echo lines throughout the script.

The important distinction is where the text goes: help text requested explicitly belongs on stdout, so it can be piped into a pager like less without friction, while help text printed on error belongs on stderr, so it does not accidentally end up in an output file when stdout has been redirected.


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

usage() {
  cat <<EOF
Usage: $0 [-v|--verbose] [-o|--output FILE] [-h|--help]

  -v, --verbose      enable verbose logging
  -o, --output FILE  write results to FILE instead of stdout
  -h, --help         show this help text and exit
EOF
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    -h|--help) usage; exit 0 ;;
    -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
    *) break ;;
  esac
done

8. Error handling: cleanly catching unknown options and missing arguments

Both with getopts and the manual variant, every script needs a clearly defined fallback for an unknown option. With getopts that is the \?) case, with the manual loop typically the pattern -*) at the end of the case branch, catching every not-yet-handled input starting with a dash before it gets misinterpreted as a positional argument.

For missing required arguments, exit 2 has become a common convention, since many standard Unix tools such as grep reserve exit code 2 for a usage error, while 1 usually signals a regular, expected failure. A consistent exit-code scheme makes scripts predictable inside larger automation chains, since calling processes can distinguish the reason for a failure from the exit code alone, without parsing the error message itself.

9. getopts compared: which tool for which command-line job

The choice between getopts, a purely manual loop, and the external getopt(1) mostly depends on whether long options are needed and how portable a script has to be. For small, internal helper scripts with a handful of short options, getopts is entirely sufficient and stays the easiest to maintain, while production-grade CLI tools with an expected --flag syntax hardly avoid a manual solution.

Approach Long options Portability Recommendation
getopts (built-in) No Anywhere Bash runs Small scripts with pure short options
Manual case loop Yes, freely Anywhere Bash runs CLI tools with a -x/--xyz syntax
GNU getopt(1) Yes Only with the GNU variant (mostly Linux) Only when a GNU environment is guaranteed
Third-party generators (e.g. argbash) Yes Depends on generated code Large CLI projects with many options

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that hold up in production?

We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.

Code Review

ShellCheck analysis and manual review for critical Bash pattern violations.

Refactoring

Retrofitting error handling, logging and safe file operations.

CI Integration

Wiring ShellCheck and BATS into pipelines and building regression tests.

10. Summary

getopts in Bash: The Essentials at a Glance

getopts

A Bash built-in for short options with OPTARG and OPTIND, available everywhere, but without support for long options.

Long options

A manual case loop over the positional parameters with shift is the portable way to support --verbose and --output=value.

Combining both

Short and long form belong in the same case branch, e.g. -v|--verbose), instead of two separate parsing passes.

Conventions

A usage() function for -h/--help, error text on stderr, exit code 2 for usage errors like missing required arguments.

11. FAQ: getopts in Bash: The Essentials at a Glance

1What is the difference between getopts and getopt?
getopts is a Bash built-in without support for long options, available everywhere. getopt is an external command whose GNU variant supports long options, but is not available in that form on every system, for example on macOS.
2Why does getopts not support --verbose?
getopts was designed for single letters after a plain dash and simply does not know the concept of multi-character option names after two dashes. Long options require a manual case loop.
3What does OPTARG stand for in getopts?
OPTARG holds the value of an option that, according to the option string, expects an argument, for example the value after -o when the string contains o:. For plain switches without a colon, OPTARG stays empty.
4Why do I need to call shift after the getopts loop?
After the loop, OPTIND points at the position of the first non-option argument. shift $((OPTIND - 1)) removes all already-processed options so that $1, $2 and so on again refer to the remaining positional arguments.
5What does a leading colon in the option string do?
A colon at the very start, as in :vo:h, enables silent error mode. getopts then stops printing its own error message and instead signals a missing argument through opt equal to a colon and an unknown option through opt equal to a question mark.
6Can I mix getopts and a manual loop in the same script?
Technically yes, but in practice it quickly causes ordering problems, because OPTIND is no longer reliable after the first unrecognized long argument. A single manual case loop handling both forms at once is more robust.
7What exit code should a script use for missing required arguments?
Exit code 2 has become the convention for usage errors, following standard tools like grep. Exit code 1 usually signals an expected, regular failure of the actual script logic instead.
8Where should help text go, stdout or stderr?
Help text explicitly requested via -h or --help belongs on stdout, so it can be redirected cleanly. Help text printed on error belongs on stderr, so it does not end up in a redirected output file.
9How do I parse --output=file.txt in pure Bash?
With the parameter expansion ${1#*=}, which strips everything up to and including the first equals sign and returns just the value after it, without calling cut or sed.
10Do I need an external tool like getopt for long options?
No. A manual case loop over the positional parameters with shift fully covers long options in pure Bash and behaves identically on Linux, macOS and inside containers, with no extra dependency.