Learn BASH Scripting - Building Professional CLI Scripts with getopts
Episode 21 of 27

Learn BASH Scripting - Building Professional CLI Scripts with getopts

A script used by other people needs an interface: flags, options that require values, help messages, and polite input handling. This episode dissects getopts as a BASH builtin, the while getopts + case pattern, OPTARG and OPTIND, the usage() function, and ends with practice building a complete, safe backup CLI script.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

In episode 20 we covered Debugging Techniques — tracing execution with bash -x, enriching traces with PS4, and finding the root cause of bugs systematically — so you now have scripts that not only work, but can be autopsied when they break. There's one final leap separating a script "used by yourself" from a script "used by other people": the interface.

Imagine sharing your backup script with a colleague. The first question from their mouth won't be "how does it work?", but "how do I use it?". If the answer is "edit the DIR variable on line 5", you've just made a tool that must be understood before it can be used — and everyone will avoid it. On the other hand, if the answer is ./backup.sh -d /var/www -v, your colleague immediately understands: there's a directory being backed up, there's a verbose mode. No need to open the file, no need to read code. A good interface is documentation that runs.

That's the job of getopts: a BASH builtin that parses command-line options in a standard, consistent way. Why not getopt? Because getopt is a separate external program with many variations between systems — whereas getopts is embedded inside BASH itself, behaves identically on every system, and is simpler for 95% of scripts. In this episode we'll dissect the while getopts "d:vh" opt syntax, understand : for options that require a value, $OPTARG for the option's value, $OPTIND for the parse position, write a usage() function, and assemble everything into a complete backup CLI script with flags, validation, and polite error messages.

Main Discussion

From Positional Arguments to Flags: Why We Need getopts

So far you've probably gotten used to positional arguments: $1, $2, $3. They work, but they have a fatal limitation for tools used by others. Notice these two invocations:

Argumen posisi yang rapuh
./backup.sh /var/www /tmp/backup-dest verbose
./backup.sh /tmp/backup-dest /var/www verbose

Both lines look "the same" but their meaning can be very different — or both might be wrong because the argument order has to be memorized. Now compare that with the flag style:

Gaya flag yang jelas
./backup.sh -s /var/www -d /tmp/backup-dest -v
./backup.sh -d /tmp/backup-dest -s /var/www -v

Flags solve two problems at once: order no longer matters, and the meaning of each value is readable from its context. This is why almost every production tool (curl -L, tar -czf, docker build -t) uses flags. getopts gives you the standard way to build those flags in your own scripts, with behavior understood by anyone who's ever used a Linux CLI.

Anatomy of getopts: Optstring, OPTARG, and OPTIND

The core syntax of getopts always takes the shape of a while loop with a case inside:

Pola dasar getopts
#!/bin/bash
 
while getopts "d:vh" opt; do
    case "$opt" in
        d)
            DEST="$OPTARG"
            ;;
        v)
            VERBOSE=true
            ;;
        h)
            usage
            exit 0
            ;;
        ?)
            usage
            exit 1
            ;;
    esac
done

Let's dissect each component one by one — this is the part most often misunderstood:

ComponentMeaning
"d:vh"Optstring: the list of supported options. A colon after a letter means that option must be followed by a value
$optThe variable holding the letter of the option currently being processed (d, v, h, or ? for unknown ones)
$OPTARGThe value accompanying the current option (filled only for options that need a value, like -d)
?The case catching unknown options, or value-requiring options that lost their value
$OPTINDThe index of the next argument to be processed — the position marker in the loop

The optstring "d:vh" reads: "-d needs a value (because of :), -v and -h don't." Note that the first colon marks the mandatory value; if you want silent error mode, the optstring begins with a colon at the very start (":d:vh") — we'll discuss the difference in the pitfalls section.

The ? pattern in case is the safety net: options you don't recognize (for example -z with the optstring above) land here. That's where you print usage() and exit with a non-zero code. With this pattern, every wrong input produces clear feedback — the mark of a polite tool.

shift $((OPTIND - 1)): Separating Flags from Positional Arguments

One important detail often forgotten: after the getopts loop finishes, the flags are fully processed, but the remaining positional arguments are still in $1, $2, and so on. The OPTIND value points to the first non-flag argument. To shift them forward, use shift:

Memisahkan flag dari argumen posisi
#!/bin/bash
 
while getopts "d:v" opt; do
    case "$opt" in
        d) DEST="$OPTARG" ;;
        v) VERBOSE=true ;;
        *) usage; exit 1 ;;
    esac
done
 
shift $((OPTIND - 1))
# Mulai dari sini, "$1" dst adalah argumen posisi murni (bukan flag)
echo "Flag diurai. Sisa argumen posisi: $*"

Why does this matter? Because many scripts combine flags and positional arguments — for example ./backup.sh -v /var/www /etc/nginx (the -v flag, then two paths as targets). Without shift, you could never separate which are flags and which are targets. The line shift $((OPTIND - 1)) is the "separating table" that keeps the two from interfering.

Note

OPTIND must be reset to 1 if you call getopts parsing more than once in the same shell session (for example when testing a script in an interactive terminal with source). Without the reset, OPTIND keeps growing from the previous call and getopts will skip arguments that should be processed. This is trap number one for scripts tested repeatedly — and it makes the pitfalls list at the end of the episode.

The usage() Function: Documentation That Runs

Every worthy CLI has a usage function explaining how to use the tool — shown when the user asks for -h or when input is invalid. This isn't a luxury; it's the basic expectation of every CLI user:

Fungsi usage() yang lengkap
#!/bin/bash
 
usage() {
    cat << EOF
Cara pakai: $0 [opsi] <sumber...>
 
Membuat backup berkompresi dari satu atau lebih direktori.
 
OPSI:
    -d DIR    Direktori tujuan backup (default: /backup)
    -v        Mode verbose, cetak detail proses
    -h        Tampilkan bantuan ini
 
CONTOH:
    $0 -d /backup -v /var/www
    $0 /etc/nginx /etc/ssl
EOF
}

Notice the details that make it professional:

  • Dynamic script name ($0) — the help stays correct even if the script is renamed or symlinked.
  • A short description of what it does and how.
  • An options list with consistent formatting: the option letter, the value it needs (if any), and an explanation.
  • An examples section — the fastest way to help people understand a tool.
  • usage prints to stdout when called from -h (a deliberate request), and to stderr when called due to input errors. This nuance separates a cared-for tool from one that's thrown together.
Membandingkan tujuan pemanggilan usage
while getopts "d:vh" opt; do
    case "$opt" in
        h) usage; exit 0 ;;                    # diminta → stdout
        ?) usage >&2; exit 1 ;;                # error → stderr
    esac
done

Tip

Print usage to stderr when input is invalid (usage >&2) and to stdout when the user asks for help (usage). The reason is practical: when the script is called in a pipeline or cron, stdout output may be processed further — your error messages must not be sucked into it. When a user deliberately types -h, stdout is the right place because they're the one reading. It's a small detail with a big impact on interface cleanliness.

Real Practice: Building a Complete Backup CLI

Now let's assemble everything into a single complete script — a professional backup CLI: a flag for the destination directory, verbose mode, validation, and positional arguments as the list of sources:

backup-cli.sh — CLI backup lengkap dengan getopts
#!/bin/bash
set -euo pipefail
 
usage() {
    cat << EOF
Cara pakai: $0 [opsi] <sumber...>
 
Membuat backup berkompresi dari satu atau lebih direktori/file.
 
OPSI:
    -d DIR    Direktori tujuan (default: /backup)
    -v        Mode verbose, cetak detail proses
    -h        Tampilkan bantuan ini
 
CONTOH:
    $0 -d /backup -v /var/www /etc/nginx
EOF
}
 
BACKUP_DIR="/backup"
VERBOSE=false
SOURCES=()
 
while getopts "d:vh" opt; do
    case "$opt" in
        d) BACKUP_DIR="$OPTARG" ;;
        v) VERBOSE=true ;;
        h) usage; exit 0 ;;
        ?) usage >&2; exit 1 ;;
    esac
done
shift $((OPTIND - 1))
 
if [[ $# -eq 0 ]]; then
    echo "Error: tidak ada sumber yang diberikan." >&2
    usage >&2
    exit 1
fi
 
mkdir -p "$BACKUP_DIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
ARCHIVE="${BACKUP_DIR}/backup-${STAMP}.tar.gz"
 
for source in "$@"; do
    [[ -e "$source" ]] || { echo "Error: '$source' tidak ditemukan." >&2; exit 1; }
done
 
if $VERBOSE; then
    echo "Tujuan : $BACKUP_DIR"
    echo "Arsip  : $ARCHIVE"
    echo "Sumber : $*"
fi
 
tar czf "$ARCHIVE" "$@"
echo "Backup selesai: $ARCHIVE"

Let's dissect the design decisions in this script:

  1. set -euo pipefail — every lesson from episode 18 applied from line two.
  2. usage as a quoted heredoc (<< EOF) — the lesson from episode 17: $0 is left to expand because we want the dynamic script name, and there are no other unwanted $ signs.
  3. Sensible defaultsBACKUP_DIR="/backup" means the script stays useful even without -d; the option just overrides the default.
  4. Positional argument validationif [[ $# -eq 0 ]] rejects empty invocations with a clear message, and the [[ -e "$source" ]] loop ensures all sources exist before tar runs (don't let tar fail midway and leave a partial archive).
  5. -v changes behavior, not just prints — verbose mode shows configuration details before working; that's an example of a flag that's genuinely useful, not decoration.
  6. "$@" for all sources"$@" preserves every argument as one intact string (the quoting lesson from episode 4), so paths with spaces stay safe.

Run it and see the results:

Menjalankan CLI backup
./backup-cli.sh -d /backup -v /var/www /etc/nginx
./backup-cli.sh -h
./backup-cli.sh
Contoh output
Tujuan : /backup
Arsip  : /backup/backup-20260802-140000.tar.gz
Sumber : /var/www /etc/nginx
Backup selesai: /backup/backup-20260802-140000.tar.gz
Cara pakai: ./backup-cli.sh [opsi] <sumber...>
...
Error: tidak ada sumber yang diberikan.
Cara pakai: ./backup-cli.sh [opsi] <sumber...>

Notice how all three invocations respond differently and appropriately: the normal one works, -h shows help and exits with 0, and the argument-less one refuses with an error message on stderr and exit code 1. This is the behavior you'd expect from a production tool — and now your script has it.

Important

Two traps to know so your CLI doesn't blow up: first, getopts handles combined options (-vh is read as -v -h) and separated options (-d /backup or -d/backup) automatically — that's great built-in behavior, but don't rely on it for value options in --dir=/backup form (that's a long-option style getopts doesn't support). Second, if your optstring needs silent mode (so getopts doesn't print its own error messages), start the optstring with a colon (":d:vh") — then $opt becomes ? for unknown options and : for options that lost their value, and the error messages are entirely yours to write.

Classic Pitfalls

1. getopts vs getopt. getopts is a BASH builtin — always available, identical behavior on all systems, and enough for single-letter options. getopt is an external program that varies between systems (util-linux version, BSD version), and even on the same version its usage differs for long options. For portable scripts, use getopts; only consider getopt when you truly need long options (--verbose), and test it on all targets.

2. Long options aren't supported. --verbose, --dir=/backup, --help are GNU styles getopts doesn't recognize — the extra - makes it read option - and then the next character. If you want long options, you must parse manually or switch to external getopt. For common needs, -v -d /backup -h is already very professional.

3. OPTIND not reset. Calling getopts twice in the same session (when source-ing a script in a terminal, or during testing) without OPTIND=1 makes parsing behave unpredictably. Always set OPTIND=1 before each getopts loop you want to start from the beginning.

4. Missing colon in the optstring → silent failure. If -d needs a value but the optstring is written "dvh" (no colon), then -d /backup reads d as a flag without a value, /backup becomes a positional argument, and no error appears — $OPTARG is never filled. This is one of the most deceptive bugs; check the optstring twice.

5. Ignoring ? and : in the case. Without a ? case, unknown options pass through with no message — the script continues on a wrong assumption. Always provide a catch-all case (?) that prints usage and exits with a non-zero code. If you use silent mode (":d:vh"), also add a : case for options that lost their value.

Caution

Be careful with shift $((OPTIND - 1)) in scripts run with set -u: OPTIND is always defined by BASH, so it's safe. What isn't safe is accessing $OPTARG outside the case — its value may contain leftovers from a previous option. Grab the $OPTARG value inside the relevant case, save it to a variable (e.g. DEST="$OPTARG"), and never rely on $OPTARG after the loop finishes.

Conclusion

In this episode 21 you've turned your script from a personal tool into a product others can use. You understand the anatomy of while getopts "d:vh" opt with its case, the role of : in the optstring for options requiring a value, $OPTARG as the value carrier, $OPTIND as the parse-position marker, and shift $((OPTIND - 1)) to separate flags from positional arguments. You also wrote a complete usage() function — documentation that runs — and assembled everything into a backup CLI with sensible defaults, input validation, verbose mode, and polite error messages.

The key takeaways:

  • getopts is a BASH builtin — consistent on every system; external getopt only for long options.
  • A : after a letter in the optstring means the option needs a value; $OPTARG takes it.
  • ? and : in the case are the safety net — never ignore them.
  • Reset OPTIND=1 when re-parsing; use shift $((OPTIND - 1)) to separate flags.
  • usage to stdout when -h is requested, to stderr on input error; correct exit codes.

With this, the past thirteen episodes have built a complete script foundation: variables, flow, functions, arrays, strings, input-output, error handling, cleanup, debugging, and now the interface. In episode 22 we'll extend your script's reach beyond its own machine with sed, awk, curl & jq integration — combining advanced text transformation, REST API calls, and JSON processing from inside a BASH script, so your scripts can talk to the whole ecosystem. See you in episode 22!