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.
Table of Contents
- 1. Why professional parameter parsing matters in Bash scripts
- 2. Basic getopts syntax: the option string, OPTARG and OPTIND
- 3. Required arguments, plain switches and the leading colon
- 4. The limits of getopts: no long options, no --option=value syntax
- 5. Adding long options by hand: a case loop over the positional parameters
- 6. Combining short and long options in the same script
- 7. Help text conventions: a usage() function, -h/--help and exit codes
- 8. Error handling: cleanly catching unknown options and missing arguments
- 9. getopts compared: which tool for which command-line job
- 10. Summary
- 11. FAQ
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.