Shell Lesson 14 of 42

Argument Parsing: getopts, getopt, Manual Parsing & the Long-Options Pattern — Building CLIs That Feel Like git

You’ve written enough scripts now to feel the friction of “first positional arg is the env, second is the tag, third is optional flag.” It works, until someone wants --dry-run. Or -v for verbose. Or sub-commands like mytool deploy vs mytool rollback. Suddenly your script needs proper argument parsing — the kind kubectl, git, aws, and every other production CLI has.

Bash gives you tools for this, but they’re not obvious:

By the end of this lesson, you’ll know when to use which, and you’ll have a copy-paste long-option parser that handles every edge case (combined flags, --key=value, --, optional vs required args).

In a nutshell

Level: Advanced · Time: ~40 min

Think about ordering a coffee: “one large oat-milk latte, decaf, to-go.” Buried in that sentence are two completely different kinds of information. There’s the thing you actually want — a latte — and there’s a pile of modifiers on it: large, oat-milk, decaf, to-go. Some modifiers carry a value (oat-milk — which milk?); others are just on/off switches (decaf, to-go). The barista’s first job, before making anything, is to pull that one order apart: the drink is the positional argument (what the command is fundamentally about), and the modifiers are the options (a.k.a. flags) that tune it. That splitting job — done in code, against "$@" — is argument parsing, and it’s the difference between a script only you can run and a tool that feels like git.

A script’s arguments arrive as one flat, ordered list of strings. ./deploy -v --config prod.yaml staging v1.2.3 is just six tokens in a row. Nothing in that list is labelled “flag” or “positional” — your parser has to decide, token by token, walking left to right: is this -v a switch? does --config need to grab the next token as its value? where do the flags stop and the real arguments (staging, v1.2.3) begin? Get the walk right and everything downstream is clean; get it wrong and a filename gets swallowed as an option’s value, or a --dry-run silently does nothing.

Bash hands you three ways to do this walk, and choosing between them is most of the lesson. getopts is a builtin trained only on the short “codes” — -v, -c FILE, even combined -vn — fast, POSIX-portable, on every machine, but it flatly does not understand long words like --verbose. A manual while/case loop is the barista who learned to understand both the codes and the spoken words (--config, --config=prod.yaml); it’s a dozen more lines but it’s fully portable and gives you total control — it’s what serious CLIs actually ship. And getopt(1) (note: no s, and a separate external program, not the builtin) is a translator you can hire who speaks long options — but only fluently on GNU/Linux; the one macOS ships is a different dialect that doesn’t do long options at all. Confusing the two getopts is the single most common trap here.

If you keep only three sentences: "$@" is an ordered token list, and parsing is walking it and sorting each token into a flag or a positional; getopts is the portable builtin for short options, a manual while/case loop is the portable way to add long options, and getopt(1) is a different, less portable external tool; and after you finish parsing, validate — parsing fills the variables, a separate step checks the combination makes sense. Everything below is the detail behind those three sentences.

Argument parsing flow read left to right: the raw ordered token list "$@" (with $0, $#, $- context) feeds a parser — either the getopts builtin for short options or a manual while/case loop for long options; the loop tracks state as it walks, with OPTARG holding the current value, OPTIND marking the next token, and a bare -- ending option scanning so the rest are positional; two caveats bite everyone — getopts' silent-vs-verbose error mode and the fragile external getopt(1) command that differs GNU vs BSD; what survives is a set of validated flags plus the leftover positional arguments in $@; six numbered badges mark argv ordering, the getopts builtin, the manual parser, the -- separator, the error modes, and getopt(1) fragility

Read the diagram left to right as the life of one argument list: the raw "$@" tokens go into a parser (the getopts builtin, or a manual while/case loop), which tracks state as it walks — OPTARG holds the current option’s value, OPTIND marks the next token to read, and a bare -- says “options are over.” Two caveats bite everyone: the silent-vs-verbose error mode of getopts, and the fact that getopt(1) is a fragile, non-portable external command. What survives is a set of validated flags plus the leftover positional arguments in $@. The six badges are the six things to actually remember; every section below expands one.

Prerequisites & what you’ll be able to do

This is an advanced lesson — it assumes you’re comfortable with the fundamentals that argument parsing is built on. You should be fluent with positional parameters and quoting ("$@" vs $*, why "$1" needs quotes) from Variables, quoting & parameter expansion — parsing leans hard on parameter expansion like ${1#*=}. You should read exit codes and case as second nature (glob-pattern case, why usage 2 exits 2) from Conditionals, exit codes & status propagation. Sub-commands are just functions that parse their own arguments, and every example here opens with the strict-mode header from Defensive scripting: set -euo pipefail & ShellCheck.

After working through this lesson you will be able to:


1. Positional arguments — the baseline

Before parsing, recap what we have:

#!/usr/bin/env bash
set -Eeuo pipefail
[[ $# -ge 2 ]] || die "usage: $0 <env> <tag>"
ENV="$1"
TAG="$2"

This works for simple cases. It breaks down once you have optional arguments or flags.

./script staging v1.2.3 --dry-run        # how do we get --dry-run out?
./script -v staging v1.2.3                # how do we get -v?

You can hand-roll all of this with if [[ "$1" == "-v" ]]; then …, but it gets ugly fast. Use a parser.


2. getopts — the bash built-in

getopts is the POSIX-standard short-option parser, built into bash. It iterates through $@ once, recognising -x and -x value patterns.

Basic usage

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

VERBOSE=0
CONFIG=""
DRY_RUN=0

while getopts ":vc:n" opt; do
  case "$opt" in
    v) VERBOSE=1 ;;
    c) CONFIG="$OPTARG" ;;
    n) DRY_RUN=1 ;;
    \?) echo "Invalid option: -$OPTARG" >&2; exit 2 ;;
    :)  echo "Option -$OPTARG requires an argument" >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))

# Now positional args are in $@
echo "verbose=$VERBOSE config='$CONFIG' dry_run=$DRY_RUN remaining=$*"

Run:

$ ./script -v -c /etc/app.conf -n staging prod
verbose=1 config='/etc/app.conf' dry_run=1 remaining=staging prod

The option string explained

":vc:n"

To declare an optional argument, getopts has no syntax for it. You can fake it (covered later) or use long-options + manual parsing.

OPTIND — the index of the next argument

getopts updates OPTIND (Option InDex) as it consumes args. After the loop:

shift $((OPTIND - 1))

This shifts away the consumed flags, leaving the positional arguments in $@.

Combined flags (-vn)

getopts supports combined short flags:

$ ./script -vn config-file
# parses as -v, -n, then "config-file" is the positional

This is standard Unix behaviour and just works.

-c=value is NOT supported

Unlike GNU long-options, getopts wants a space:

./script -c /etc/app.conf      # CORRECT
./script -c=/etc/app.conf      # WRONG — getopts treats "=/etc/..." as the value

This is the #1 surprise for newcomers.

-- ends option parsing

The -- separator says “no more flags; rest are positional”:

./script -v -- -file-with-leading-dash.txt
# -v is parsed; then -- signals end; the dash-file is the positional

getopts handles this automatically.

Error handling — \? and :

In silent mode (option string starts with :):

Without leading :, getopts prints its own errors but still uses ? for both cases — you can’t tell them apart, and the message is “illegal option” — not very informative. Always use silent mode.

A more complete example with usage()

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

readonly SCRIPT="${0##*/}"

usage() {
  cat <<EOF
Usage: $SCRIPT [-v] [-n] [-c CONFIG] <env> <tag>

  -v          verbose mode
  -n          dry run; don't actually deploy
  -c CONFIG   path to config file (default: \$HOME/.app.conf)

Examples:
  $SCRIPT staging v1.2.3
  $SCRIPT -v -n -c ./test.conf prod v1.2.3
EOF
  exit "${1:-0}"
}

VERBOSE=0
DRY_RUN=0
CONFIG="${HOME}/.app.conf"

while getopts ":vnc:h" opt; do
  case "$opt" in
    v) VERBOSE=1 ;;
    n) DRY_RUN=1 ;;
    c) CONFIG="$OPTARG" ;;
    h) usage 0 ;;
    \?) printf 'Invalid option: -%s\n\n' "$OPTARG" >&2; usage 2 ;;
    :)  printf 'Option -%s requires an argument\n\n' "$OPTARG" >&2; usage 2 ;;
  esac
done
shift $((OPTIND - 1))

[[ $# -eq 2 ]] || usage 2
ENV="$1"; TAG="$2"

echo "ENV=$ENV TAG=$TAG VERBOSE=$VERBOSE DRY_RUN=$DRY_RUN CONFIG=$CONFIG"

This is the production-grade getopts pattern. Use it when short options are enough.

getopts summary

Feature Supported
Short options -v yes
Short option with arg -c FILE yes
Combined flags -vn yes
Long options --verbose no
--key=value no
Optional argument no (workaround possible)
Sub-commands no (do it yourself)

When you need long options, switch to manual parsing or GNU getopt.


3. Manual long-option parsing — the canonical pattern

For full power and full portability (works on any bash), use a while loop with a case over $1, shifting as you go.

The template

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

VERBOSE=0
DRY_RUN=0
CONFIG=""
ENV=""
TAG=""

usage() {
  cat <<EOF
Usage: ${0##*/} [OPTIONS] <env> <tag>

Options:
  -v, --verbose          verbose mode
  -n, --dry-run          don't actually deploy
  -c, --config FILE      path to config file
  -h, --help             show this help
EOF
  exit "${1:-0}"
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    -v|--verbose)
      VERBOSE=1
      shift
      ;;
    -n|--dry-run)
      DRY_RUN=1
      shift
      ;;
    -c|--config)
      [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; usage 2; }
      CONFIG="$2"
      shift 2
      ;;
    --config=*)
      CONFIG="${1#*=}"
      shift
      ;;
    -h|--help)
      usage 0
      ;;
    --)
      shift
      break
      ;;
    -*)
      echo "Unknown option: $1" >&2
      usage 2
      ;;
    *)
      # First non-option argument breaks out; rest are positional
      break
      ;;
  esac
done

# Remaining are positional
[[ $# -eq 2 ]] || usage 2
ENV="$1"
TAG="$2"

echo "ENV=$ENV TAG=$TAG VERBOSE=$VERBOSE DRY_RUN=$DRY_RUN CONFIG=$CONFIG"

This pattern handles:

This is what most production shell scripts use. Master this template and copy-paste it.

Variations

Multiple values: collect into an array

INCLUDE=()

case "$1" in
  -i|--include)
    INCLUDE+=("$2")
    shift 2
    ;;
  --include=*)
    INCLUDE+=("${1#*=}")
    shift
    ;;
esac

# Use:
for path in "${INCLUDE[@]}"; do …; done

Now --include foo --include bar --include baz builds up a list. (Indexed and associative arrays get their own lesson later in the course.)

Counting occurrences (e.g. -vvv)

VERBOSE=0

case "$1" in
  -v|--verbose) VERBOSE=$((VERBOSE+1)); shift ;;
  -vv) VERBOSE=$((VERBOSE+2)); shift ;;
  -vvv) VERBOSE=$((VERBOSE+3)); shift ;;
esac

For real -v -v -v (separate args), the first case repeated handles it. For -vvv (combined), we need explicit cases or a parser that decomposes combined short flags (rare in shell — most scripts don’t bother).

Optional arguments to a flag

case "$1" in
  --color)
    # check if next arg is a recognised colour mode or another flag
    if [[ $# -ge 2 && "$2" != -* ]]; then
      COLOR="$2"
      shift 2
    else
      COLOR="auto"
      shift
    fi
    ;;
  --color=*)
    COLOR="${1#*=}"
    shift
    ;;
esac

This is messy — it’s why “optional argument to flag” is unusual in CLIs. Most tools just use --color=value (mandatory =) for optional values. The reason it’s messy is genuinely ambiguous input: with --color always you want to consume always, but with --color file.txt (where file.txt is meant to be a positional!) the peek-the-next-arg heuristic will wrongly swallow file.txt as the colour. The = form removes the ambiguity, which is why every well-behaved CLI reaches for it.

Boolean flags with --no- form

case "$1" in
  --color)    COLOR=1; shift ;;
  --no-color) COLOR=0; shift ;;
esac

Standard pattern. --color enables, --no-color disables explicitly. Useful when the default is configurable and the user wants to override.


4. GNU getopt — long options without rolling your own

GNU getopt (the external command, different from bash getopts built-in) handles long options for you. It’s not portable to macOS by default — macOS ships a BSD getopt that doesn’t support long options. You either:

Detection

# GNU getopt's --test exits with status 4 — and nothing else does. That is the
# sentinel we key off. Two subtleties make this a classic bug magnet:
#   1. Put the probe INSIDE the if-condition list, so `set -e` (errexit) does
#      NOT abort on the non-zero status 4 (errexit is suppressed in an if test).
#   2. Do NOT negate it with `!`. Negation inverts getopt's exit code *before*
#      $? is read, so the 4 becomes a 1 and the [[ $? -ne 4 ]] check misfires
#      on real GNU systems — reporting "no GNU getopt" when GNU getopt is right
#      there. (Section "Going deeper" dissects exactly why.)
if getopt --test >/dev/null 2>&1; [[ $? -ne 4 ]]; then
  echo "this script requires GNU getopt; on macOS: brew install gnu-getopt" >&2
  exit 1
fi

GNU getopt’s --test option exits with status 4 specifically. BSD getopt doesn’t recognise --test and exits with a different status (0 on macOS), so the [[ $? -ne 4 ]] guard fires and we tell the user to install GNU getopt.

The pattern

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

# Define short and long option strings
SHORT="vnc:h"
LONG="verbose,dry-run,config:,help"

# Run getopt to canonicalise; trap errors
PARSED=$(getopt --options="$SHORT" --longoptions="$LONG" --name "$0" -- "$@") || { usage 2; }

# Reset the positional args to the canonicalised form
eval set -- "$PARSED"

# Now parse normally with case (no need to handle --opt=value; getopt already split it)
VERBOSE=0; DRY_RUN=0; CONFIG=""

while true; do
  case "$1" in
    -v|--verbose) VERBOSE=1; shift ;;
    -n|--dry-run) DRY_RUN=1; shift ;;
    -c|--config)  CONFIG="$2"; shift 2 ;;
    -h|--help)    usage 0 ;;
    --)           shift; break ;;
    *)            echo "internal error" >&2; exit 2 ;;
  esac
done

ENV="$1"; TAG="$2"

The trick is eval set -- "$PARSED": getopt outputs a canonicalised, properly-quoted argument list, and eval set -- re-applies it as the new $@. After that, parsing is straightforward.

Why getopt is convenient: it handles --config=FILE for you (splits to --config FILE), supports option abbreviations (--ver matches --verbose if unambiguous), groups short flags, and — unlike our simple manual loop — permutes the arguments so options and positionals can be interleaved (getopt moves all the positionals to the end). You don’t need to handle the --config=* case or worry about -vn.

Why getopt is annoying:

For new scripts, manual parsing (Section 3) is usually preferable. Use getopt only if you’re Linux-locked and the script has many options.


5. Sub-commands — the git/kubectl style

Real CLIs have sub-commands: git commit, kubectl apply, aws s3 cp. Build this with a case on the first non-option argument:

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

usage_main() {
  cat <<EOF
Usage: ${0##*/} [GLOBAL_OPTIONS] <command> [COMMAND_OPTIONS] [ARGS]

Commands:
  deploy <env> <tag>       deploy a tag to an env
  rollback <env>           rollback the most recent deploy
  status <env>             show current deployed version

Global options:
  -v, --verbose            verbose mode
  -h, --help               show help
EOF
  exit "${1:-0}"
}

# Parse global options
VERBOSE=0
while [[ $# -gt 0 ]]; do
  case "$1" in
    -v|--verbose) VERBOSE=1; shift ;;
    -h|--help) usage_main 0 ;;
    -*) echo "Unknown global option: $1" >&2; usage_main 2 ;;
    *) break ;;     # first non-option arg is the sub-command
  esac
done

[[ $# -ge 1 ]] || usage_main 2
COMMAND="$1"
shift

# Dispatch
case "$COMMAND" in
  deploy)   cmd_deploy "$@" ;;
  rollback) cmd_rollback "$@" ;;
  status)   cmd_status "$@" ;;
  -h|--help|help) usage_main 0 ;;
  *) echo "Unknown command: $COMMAND" >&2; usage_main 2 ;;
esac

Each cmd_* function does its own argument parsing:

cmd_deploy() {
  local env="" tag="" dry_run=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -n|--dry-run) dry_run=1; shift ;;
      -*) echo "deploy: unknown option $1" >&2; exit 2 ;;
      *) break ;;
    esac
  done
  [[ $# -eq 2 ]] || { echo "deploy: usage: deploy <env> <tag>" >&2; exit 2; }
  env="$1"; tag="$2"
  echo "deploying $tag to $env (dry_run=$dry_run, verbose=$VERBOSE)"
}

cmd_rollback() { … }
cmd_status()   { … }

This scales to dozens of sub-commands. Most CLIs end up with a lib/cmd_*.sh file per command and an entry point that just dispatches.

Auto-discovery of sub-commands

Even cooler — discover sub-commands at runtime by looking for cmd_* functions:

list_commands() {
  declare -F | awk '$NF ~ /^cmd_/ { sub(/^cmd_/, "", $NF); print $NF }'
}

declare -F lists all defined functions; we filter for cmd_* and strip the prefix. Now myapp help can list known commands without hard-coding them.


6. The usage() function pattern

Every CLI needs a usage function. Conventions:

usage() {
  cat <<EOF
Usage: ${0##*/} [OPTIONS] <env> <tag>

Description:
  Deploy a tag to a Kubernetes namespace.

Options:
  -v, --verbose          verbose output
  -n, --dry-run          show what would happen, but don't deploy
  -c, --config FILE      path to config file (default: \$HOME/.app.conf)
  -h, --help             show this help

Arguments:
  env                    target environment: dev, staging, prod
  tag                    image tag in vMAJOR.MINOR.PATCH form

Examples:
  ${0##*/} staging v1.2.3
  ${0##*/} -v --dry-run prod v1.2.3
  ${0##*/} --config ~/.app.staging.conf staging v1.2.3

Exit status:
  0  success
  1  general error
  2  invalid usage
EOF
  exit "${1:-0}"
}

Conventions:

For really comprehensive CLIs, generate the usage from a structured definition:

declare -A OPT_DESC=(
  [v|verbose]="verbose output"
  [n|dry-run]="don't actually deploy"
  [c|config FILE]="path to config file"
  [h|help]="show this help"
)

That’s overkill for most scripts but useful for very large ones. Most stop at static here-docs.


7. Default values from environment

Production scripts often allow defaults to come from environment variables (so CI can set them without command-line clutter):

# Default to env var, fall back to literal default
ENV="${TARGET_ENV:-dev}"
NAMESPACE="${KUBE_NAMESPACE:-default}"

# CLI overrides env var; env var overrides hard-coded default
while [[ $# -gt 0 ]]; do
  case "$1" in
    -e|--env) ENV="$2"; shift 2 ;;
    -n|--namespace) NAMESPACE="$2"; shift 2 ;;
    *) break ;;
  esac
done

This precedence (CLI > env > default) is standard for almost every CLI — kubectl, terraform, aws, etc. Implement it consistently.

--config as a YAML/JSON file

For complex configs, accept a config file:

CONFIG_FILE=""
ENV=""
NAMESPACE=""

# Parse args (CONFIG_FILE may be set here)

# Load config if specified
if [[ -n "$CONFIG_FILE" ]]; then
  [[ -r "$CONFIG_FILE" ]] || die "cannot read config: $CONFIG_FILE"
  ENV=$(yq '.env' "$CONFIG_FILE")
  NAMESPACE=$(yq '.namespace' "$CONFIG_FILE")
fi

# Now apply env-var overrides
ENV="${TARGET_ENV:-$ENV}"
NAMESPACE="${KUBE_NAMESPACE:-$NAMESPACE}"

# CLI overrides come last (already applied during parse, since we set ENV directly there)

The precedence stack: hard-coded default → config file → environment variable → command line.


8. Validation after parsing

After parsing, validate:

[[ -n "$ENV" ]] || die "missing required: env"
[[ -n "$TAG" ]] || die "missing required: tag"
[[ "$ENV" =~ ^(dev|staging|prod)$ ]] || die "invalid env: $ENV"
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "invalid tag: $TAG"
[[ -z "$CONFIG" || -r "$CONFIG" ]] || die "config not readable: $CONFIG"

Always validate after parsing, never during. Parsing should just fill in the variables; a separate validation phase checks that the combination is sensible. This separates “user typed a bad option” from “config file doesn’t exist.”


9. Common pitfalls

Forgetting shift in a case branch

while [[ $# -gt 0 ]]; do
  case "$1" in
    -v) VERBOSE=1 ;;             # MISSING shift — infinite loop
  esac
done

Always shift (or shift 2 for options-with-values, or break to stop). The loop iterates until $# is 0.

Using $1 after shift without re-checking $#

case "$1" in
  -c|--config)
    shift
    CONFIG="$1"        # if user wrote `-c` with no value, $1 is unset; -u fires
    ;;
esac

Always check $# first:

case "$1" in
  -c|--config)
    [[ $# -ge 2 ]] || die "missing value for $1"
    CONFIG="$2"
    shift 2
    ;;
esac

Not handling --

If your script accepts pass-through args (./script --verbose -- some-other-tool --its-flag), the -- separator is essential. Always include it:

case "$1" in
  --) shift; break ;;
esac

getopt vs getopts confusion

They are different programs:

The error “getopts unrecognized option” usually means you typed getopt when you wanted getopts. The reverse is also a common bug. Triple-check spelling.

$OPTARG not in your case

After case "$opt", $OPTARG is the value (for options that take one). Don’t forget the colon in the option string, or OPTARG is empty:

while getopts ":vc" opt; do      # missing colon after c
  case "$opt" in
    c) CONFIG="$OPTARG" ;;       # OPTARG is empty
  esac
done

The fix: getopts ":vc:" opt — the trailing : after c.

Subcommand args being parsed by global parser

./tool deploy -v staging v1.2.3
# If the global parser consumes -v before reaching `deploy`, the sub-command never sees it

Either:

Long-option parsing with = mid-value

--config=/etc/app/config.yaml

In your manual parser, ${1#*=} strips everything up to and including the first =, leaving /etc/app/config.yaml. Good.

--config=foo=bar

${1#*=} strips only to the first =, giving foo=bar. Also good.

But:

--config            # no value, no =

${1#*=} returns the whole $1 (no = to strip), so CONFIG="--config". Wrong. Detect this:

case "$1" in
  --config=*)
    CONFIG="${1#*=}"
    [[ -n "$CONFIG" ]] || die "missing value for --config"
    shift
    ;;
  --config)
    [[ $# -ge 2 ]] || die "missing value for --config"
    CONFIG="$2"
    shift 2
    ;;
esac

Always handle both forms explicitly.

Combined short flags with manual parser

getopts handles -vn (combined). Manual parsers don’t, by default. To support it:

# Decompose -vn into -v -n before parsing
ARGS=()
for arg in "$@"; do
  case "$arg" in
    -[a-zA-Z][a-zA-Z]*)
      # short flag combination — split each character into its own flag
      i=1
      while [[ $i -lt ${#arg} ]]; do
        ARGS+=("-${arg:$i:1}")
        ((i++))
      done
      ;;
    *)
      ARGS+=("$arg")
      ;;
  esac
done
set -- "${ARGS[@]}"

# Now parse normally — combined flags have been split

This is rarely necessary; most CLIs require separate flags (-v -n). Document it if you don’t support combined short flags.


10. Twelve idioms for daily use

# 1. getopts skeleton
while getopts ":vnc:h" opt; do
  case "$opt" in
    v) VERBOSE=1 ;;
    n) DRY_RUN=1 ;;
    c) CONFIG="$OPTARG" ;;
    h) usage 0 ;;
    \?) usage 2 ;;
    :)  echo "missing arg for -$OPTARG" >&2; usage 2 ;;
  esac
done
shift $((OPTIND - 1))

# 2. Manual long-option parser (template)
while [[ $# -gt 0 ]]; do
  case "$1" in
    -v|--verbose) VERBOSE=1; shift ;;
    -c|--config) CONFIG="$2"; shift 2 ;;
    --config=*)  CONFIG="${1#*=}"; shift ;;
    -h|--help)   usage 0 ;;
    --)          shift; break ;;
    -*)          echo "Unknown: $1" >&2; usage 2 ;;
    *)           break ;;
  esac
done

# 3. usage with optional exit code
usage() { cat <<EOF
Usage: ${0##*/} [-v] <env> <tag>
EOF
  exit "${1:-0}"
}

# 4. Default from env, override from CLI
ENV="${TARGET_ENV:-dev}"

# 5. Validate option arg
[[ $# -ge 2 ]] || die "missing value for $1"

# 6. Multi-value array
INCLUDE=()
case "$1" in
  -i|--include) INCLUDE+=("$2"); shift 2 ;;
esac

# 7. Boolean with --no- form
case "$1" in
  --color)    COLOR=1; shift ;;
  --no-color) COLOR=0; shift ;;
esac

# 8. Sub-command dispatcher
case "$COMMAND" in
  deploy) cmd_deploy "$@" ;;
  status) cmd_status "$@" ;;
  *)      die "unknown command: $COMMAND" ;;
esac

# 9. Auto-discover commands
list_commands() {
  declare -F | awk '$NF ~ /^cmd_/ { sub(/^cmd_/, "", $NF); print $NF }'
}

# 10. Detect GNU getopt
getopt --test >/dev/null 2>&1
[[ $? -eq 4 ]] || die "GNU getopt required"

# 11. Reset $@ from getopt output
PARSED=$(getopt --options="$SHORT" --longoptions="$LONG" -- "$@") || usage 2
eval set -- "$PARSED"

# 12. Validate environment after parsing
[[ "$ENV" =~ ^(dev|staging|prod)$ ]] || die "invalid env: $ENV"

11. What you must internalise before lesson 15


Going deeper

You now have the working model. This section is the internals, edge cases, and production judgement that separate a parser that “works when I test it” from one that behaves correctly under strict mode, inside functions, on other platforms, and against hostile input.

getopts keeps its state in globals — reset OPTIND before you reuse it

getopts is not a pure function: it advances a global OPTIND (and sets a global OPTARG) as it walks. That has two consequences people trip over.

First, if you run two getopts loops in the same shell — for example a script that parses once, then sources something that parses again, or a loop you re-run — the second loop starts wherever the first one left OPTIND, not at 1. At the top of a fresh parse you may need to reset it explicitly:

OPTIND=1
while getopts ":v" opt; do … done

Second, and far more common in real code: if you parse arguments inside a function (every sub-command does), declare OPTIND — and usually OPTARG and opt — as local, so each call parses from a clean slate and the caller’s parsing state isn’t clobbered:

cmd_deploy() {
  local OPTIND=1 opt dry_run=0
  while getopts ":n" opt; do
    case "$opt" in n) dry_run=1 ;; esac
  done
  shift $((OPTIND - 1))
  echo "deploy dry_run=$dry_run env=$1 tag=$2"
}
cmd_deploy -n staging v1     # OPTIND is local → reentrant
cmd_deploy prod v2           # fresh call, fresh OPTIND → parses correctly

Making OPTIND local is the single most important habit for getopts-inside-functions; forget it and the second invocation silently mis-parses because OPTIND still points past the first call’s options.

There’s a lesser-known third mode: getopts can parse an explicit list instead of "$@"getopts optstring name arg1 arg2 …. This is handy for parsing a captured array (e.g. a sub-command’s arguments held in an array). Note that with an explicit list, shift $((OPTIND-1)) is meaningless (there’s nothing to shift); instead you index the leftovers as "${list[@]:OPTIND-1}":

args=(-v -c file.txt keep1 keep2)
OPTIND=1
while getopts ":vc:" opt "${args[@]}"; do
  case "$opt" in v) V=1 ;; c) C="$OPTARG" ;; esac
done
positional=( "${args[@]:OPTIND-1}" )   # → (keep1 keep2)

Faking an optional argument to getopts (and why you shouldn’t)

getopts has no syntax for “this option takes an optional argument.” The commonly cited hack declares the option with no trailing colon, then peeks at the next raw token via indirect expansion ${!OPTIND}:

while getopts ":a" opt; do
  case "$opt" in
    a)
      next="${!OPTIND-}"
      if [[ -n "$next" && "$next" != -* ]]; then
        AVAL="$next"; OPTIND=$((OPTIND+1))     # consume it manually
      else
        AVAL="auto"                            # no value supplied
      fi ;;
  esac
done

It works — -a red gives red, bare -a gives auto — but it inherits the exact ambiguity we saw with the manual parser: a positional argument that legitimately follows -a gets eaten. This is precisely the signal to stop fighting getopts. If you need optional-argument options, that requirement alone is a good reason to switch to the manual while/case parser (or mandate the --opt=value form), where the intent is explicit and the code is readable.

Why getopt(1) is genuinely fragile — the full dissection

The single most-copied broken snippet for detecting GNU getopt looks like this:

if ! getopt --test >/dev/null 2>&1; [[ $? -ne 4 ]]; then   # BUG
  echo "requires GNU getopt" >&2; exit 1
fi

Walk it through on a real GNU system, where getopt --test exits 4:

  1. ! getopt --test runs the command (exit 4), then the leading ! inverts it. Since 4 is “failure”, ! turns the whole thing into success — exit 0.
  2. $? is therefore 0, not 4 — the ! has already thrown the 4 away.
  3. [[ 0 -ne 4 ]] is true, so the if fires and prints “requires GNU getopt” — on a machine that has GNU getopt. The check is exactly backwards.

The fix is simply to drop the !, keeping the probe inside the if-condition list so set -e doesn’t abort on the 4:

if getopt --test >/dev/null 2>&1; [[ $? -ne 4 ]]; then     # correct
  echo "requires GNU getopt; on macOS: brew install gnu-getopt" >&2; exit 1
fi

Now on GNU: getopt --test exits 4, $? is 4, [[ 4 -ne 4 ]] is false → we proceed. On BSD/macOS: getopt --test doesn’t recognise the flag and exits 0, [[ 0 -ne 4 ]] is true → we bail with the helpful message. This is verified behaviour: on this macOS host getopt --test exits 0; a simulated GNU getopt returning 4 correctly falls through the fixed form and (wrongly) trips the negated form. Two facts make the corrected version robust: getopt --test’s exit 4 is a documented sentinel unique to GNU getopt, and a non-zero status inside an if-condition list is exempt from set -e, so the probe won’t kill a strict-mode script.

Beyond detection, three more getopt(1) sharp edges:

Net: getopt(1) buys you long options, abbreviation, and argument permutation “for free,” but at the cost of an external dependency, a platform split, and a detection dance. For anything you might run on a mac or ship widely, the manual parser (Section 3) is the safer default.

Argument permutation: why option order matters (and where it doesn’t)

Our simple manual loop stops at the first non-option (*) break ;;), so ./script staging -v treats -v as a positional — options must come before positionals. getopts behaves the same way. GNU getopt, by contrast, permutes: it scans the whole list, pulls options out from anywhere, and moves positionals to the end, so ./script staging -v still sees -v as a flag. Neither is “right” — but you must document which your CLI does, because users absolutely will type myscript file --verbose and expect it to work. If you want permutation without getopt(1), do two passes: collect positionals into an array on the first pass, parse options on the second.

Performance: parsing is essentially free — the cost is elsewhere

getopts, case, [[ ]], shift, and parameter expansion like ${1#*=} are all in-process — no fork, no exec. Even a loop over a few hundred arguments costs microseconds. The performance mistakes in argument handling are never the parser itself; they’re what you do per argument: shelling out to sed/grep/awk to validate each value (two forks apiece), or calling getopt(1) (an external process) when a builtin would do. Keep validation in-shell ([[ =~ ]], case, arithmetic) and reserve external tools for genuinely text-heavy work. This is the same “stay in the shell” rule the performance lesson hammers on — it applies squarely to parsing.

Security: parsed arguments are untrusted input

Everything a user (or a CI job, or a webhook) passes on the command line is untrusted, and the parser is the first gate. Three rules:

  1. Quote every expansion, always. CONFIG="$2", case "$1" in, shift-then-use "$1". An unquoted $2 containing spaces or globs word-splits or file-globs into something you didn’t intend — a classic injection vector.
  2. Validate as an allow-list, after parsing, not during. [[ "$ENV" =~ ^(dev|staging|prod)$ ]] rejects anything unexpected; a deny-list (“block ..”) always misses a case. Parsing fills variables; a separate validation phase decides whether the combination is acceptable and safe.
  3. Never eval user-controlled argument text. The eval set -- "$PARSED" idiom is safe only because it consumes GNU getopt’s own quoted output. Never eval a value that came straight from "$@". If a flag’s value later reaches a command, pass it as a data argument (cmd -- "$VALUE"), not spliced into a command string. Injection, quoting, and IFS attacks get a full treatment in the dedicated shell-security lesson.

Portability matrix

Feature getopts (builtin) Manual while/case getopt(1) (external)
On every POSIX shell ✅ yes ✅ yes ❌ external dependency
Works on macOS out of the box ⚠️ BSD getopt: no long opts
Short options -v, -c FILE, -vn ✅ (combined needs extra code)
Long options --verbose, --config=X ✅ (GNU only)
Optional-argument options ❌ (hack only) ⚠️ awkward
Argument permutation (opts after positionals) ❌ (add a pass)
Abbreviation (--ver--verbose) ✅ (GNU)
Extra dependency / detection dance none none yes

The honest default: reach for getopts when short options are enough (most scripts), the manual parser when you want long options or portability with control (most serious CLIs), and getopt(1) only when you’re Linux-locked and specifically want permutation/abbreviation. Detecting the shell and gating bashisms is covered in the POSIX-portability-versus-bashisms lesson.


Common beginner mistakes

These are misconceptions, not just typos — each is a wrong mental model, followed by the right one.


Practice challenges

Work these in order — they escalate from a first getopts loop to fixing a real portability bug. Try each in a real shell before expanding the solution. (This host is macOS/bash 3.2 + BSD getopt; the answers target Linux bash 4+/5 + GNU coreutils, the course standard — where they differ it’s noted.)

1. (Beginner) Your first getopts loop. Write a loop that accepts -v (a switch) and -o FILE (takes a value), sets VERBOSE and OUT, then prints them plus the leftover positionals. Before running, predict what -o with no value does in silent mode.

<details> <summary>Solution</summary>

VERBOSE=0; OUT=""
while getopts ":vo:" opt; do
  case "$opt" in
    v) VERBOSE=1 ;;
    o) OUT="$OPTARG" ;;
    :)  echo "missing value for -$OPTARG" >&2; exit 2 ;;
    \?) echo "unknown option -$OPTARG" >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))
echo "verbose=$VERBOSE out='$OUT' rest=$*"
# ./s -v -o log.txt a b  -> verbose=1 out='log.txt' rest=a b
# ./s -o                 -> "missing value for -o" (exit 2)

Why: the trailing colon in o: makes -o require an argument; in silent mode (leading :) a missing one lands in the : case with OPTARG=o. </details>

2. (Beginner) Recover the positionals. Given the loop above, someone runs ./s -v -o out.txt staging prod. Which single line leaves only staging prod in $@, and why does removing it break the script?

<details> <summary>Solution</summary>

shift $((OPTIND - 1))    # OPTIND is 4 here → shift away the 3 consumed option tokens (-v -o out.txt)

Why: getopts advances OPTIND to the index of the first non-option (4). Without the shift, $1 is still -v, and $@ is the whole original list — so your “first positional” logic reads the flags instead of staging. </details>

3. (Intermediate) A long-option parser. Write a while/case loop that accepts -o|--out FILE and --out=FILE, plus -v|--verbose, ends cleanly on --, and rejects unknown -* options. Test it with --out=a.txt, --out b.txt, and -- -weird.

<details> <summary>Solution</summary>

VERBOSE=0; OUT=""
while [[ $# -gt 0 ]]; do
  case "$1" in
    -v|--verbose) VERBOSE=1; shift ;;
    -o|--out) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; OUT="$2"; shift 2 ;;
    --out=*)  OUT="${1#*=}"; shift ;;
    --) shift; break ;;
    -*) echo "unknown option $1" >&2; exit 2 ;;
    *) break ;;
  esac
done
echo "verbose=$VERBOSE out='$OUT' rest=[$*]"
# --out=a.txt        -> out='a.txt'
# --out b.txt x      -> out='b.txt' rest=[x]
# -v -- -weird       -> rest=[-weird]   (-- ended option scanning)

Why: --out FILE and --out=FILE are genuinely different token shapes, so they need separate branches; ${1#*=} extracts the value from the = form; --) shift; break hands a dash-leading positional through untouched. </details>

4. (Intermediate) Make -- earn its keep. Write a one-liner-ish wrapper mygrep that greps for a pattern which may itself start with a dash (e.g. -v), passing it to grep safely. Show why grep "$pat" file fails and grep -- "$pat" file works.

<details> <summary>Solution</summary>

mygrep() {
  local pat="$1"; shift
  grep -- "$pat" "$@"          # -- tells grep "the pattern is data, not a flag"
}
mygrep -v access.log           # WITHOUT --: grep sees -v as its "invert match" flag → wrong results

Why: -- marks the end of options for the receiving command too. Without it, a pattern like -v is misread as grep’s invert-match flag; grep -- "$pat" forces everything after to be treated as operands. </details>

5. (Advanced) A two-verb sub-command CLI. Build tool up|down [-f] where the top level parses a global -v, dispatches to cmd_up/cmd_down, and each sub-command parses its own -f (force) flag with a reentrant getopts. Verify tool -v up -f and tool down both work.

<details> <summary>Solution</summary>

#!/usr/bin/env bash
set -Eeuo pipefail
VERBOSE=0
cmd_up()   { local OPTIND=1 opt f=0; while getopts ":f" opt; do case "$opt" in f) f=1;; esac; done; echo "up   force=$f verbose=$VERBOSE"; }
cmd_down() { local OPTIND=1 opt f=0; while getopts ":f" opt; do case "$opt" in f) f=1;; esac; done; echo "down force=$f verbose=$VERBOSE"; }

while [[ $# -gt 0 ]]; do
  case "$1" in
    -v|--verbose) VERBOSE=1; shift ;;
    -*) echo "unknown global option $1" >&2; exit 2 ;;
    *) break ;;
  esac
done
[[ $# -ge 1 ]] || { echo "need a command" >&2; exit 2; }
cmd="$1"; shift
case "$cmd" in
  up)   cmd_up "$@" ;;
  down) cmd_down "$@" ;;
  *) echo "unknown command $cmd" >&2; exit 2 ;;
esac
# tool -v up -f  -> up   force=1 verbose=1
# tool down      -> down force=0 verbose=0

Why: the global loop stops at the first non-option so the sub-command name and its flags survive; local OPTIND=1 makes each sub-command’s getopts reentrant so the second verb parses from a clean slate. </details>

6. (Advanced) Fix the getopt(1) detection bug. This guard is supposed to bail only when GNU getopt is absent, but on a real GNU/Linux box it wrongly reports “requires GNU getopt.” Explain the flaw and give the one-token fix.

if ! getopt --test >/dev/null 2>&1; [[ $? -ne 4 ]]; then
  echo "requires GNU getopt" >&2; exit 1
fi

<details> <summary>Solution</summary>

The ! inverts getopt --test’s exit code before $? is read. GNU getopt exits 4, but ! (exit 4) becomes exit 0, so $? is 0, [[ 0 -ne 4 ]] is true, and the guard fires on a machine that has GNU getopt. Drop the !:

if getopt --test >/dev/null 2>&1; [[ $? -ne 4 ]]; then
  echo "requires GNU getopt; on macOS: brew install gnu-getopt" >&2; exit 1
fi

Why: GNU getopt’s --test uniquely exits 4; keeping the probe inside the if-condition list means set -e won’t abort on that non-zero, and not negating it preserves the 4 so [[ $? -ne 4 ]] reads the real status. (Verified: on GNU the fixed form proceeds; on BSD/macOS, where --test exits 0, it correctly bails.) </details>


Glossary


What’s next

Lesson 15: Logging Frameworks — syslog/journald, Structured Logs, Levels & Rotation. We’ll move beyond info/warn/die printing to stderr and look at proper logging: syslog integration, structured (key=value or JSON) logs, log levels with thresholds, output destinations (stderr/file/journald), rotation, and the canonical lib/log.sh you can drop into any script. After L15 your scripts will leave traces that downstream tools (Loki, Splunk, Elastic) can actually parse.

See you there.

shellbashgetoptsgetoptcliargument-parsinglong-optionssubcommandsproduction
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments