Right, so you’ve written a bash script. It’s beautiful. It does one thing perfectly. Now you want to make it useful for other people, or for future-you at 2 AM, which means it needs to handle command-line options. You could manually parse $1, $2, etc. I’ve done it. You’ve done it. It’s a mess that quickly spirals into a nested if nightmare of checking for -- and -.

Don’t do that. Bash gives you getopts, a built-in command designed specifically to save you from that particular brand of self-flagellation. It’s not without its quirks (it’s a bash builtin, after all), but it’s the right tool for the job.

The Absolute Basics: How getopts Works

getopts is a command that gets called in a while loop. Its job is to iterate through the options you provide to your script, parsing them one by one. The syntax looks a bit arcane at first, but it becomes second nature.

#!/bin/bash

# This is the options string: each letter is an option.
# A colon : after a letter means that option requires an argument.
while getopts "a:bc" opt; do
  case $opt in
    a)
      echo "Option -a was triggered! Argument: $OPTARG" >&2
      ;;
    b)
      echo "Option -b was triggered!" >&2
      ;;
    c)
      echo "Option -c was triggered!" >&2
      ;;
    \?)
      echo "Invalid option: -$OPTARG" >&2
      exit 1
      ;;
  esac
done

Save this as test.sh and run it:

$ ./test.sh -b -a myfile.txt -c
Option -b was triggered!
Option -a was triggered! Argument: myfile.txt
Option -c was triggered!

See what happened? getopts did all the heavy lifting of:

  1. Recognizing -a, -b, and -c.
  2. Knowing that -a needed an argument and correctly assigning myfile.txt to it (which it stores in the $OPTARG variable).
  3. Handling the order gracefully. You could run ./test.sh -a myfile.txt -bc and it would work exactly the same.

The opt variable holds the letter of the option it’s currently processing, and $OPTARG holds the argument for that option, if one is required.

The Infuriating Limitation: Why Not –long-options?

Here’s the part where I have to be honest with you: getopts is a relic. It only handles single-letter options (-a, -v). It does not, by itself, handle GNU-style long options (--verbose, --all). This is its single biggest flaw and the reason many people reach for getopt (note the missing ’s’), an external program, instead.

Before you go down that road, know that getopt has its own nightmares involving whitespace and quoting. For probably 90% of scripts, the clarity of --long-options isn’t worth the portability and parsing headaches it introduces. My advice? Use getopts for its simplicity and predictability. If you absolutely need long options, you can simulate them with a little extra code, often by using a single-letter option as an alias (e.g., -v and --verbose both set the same verbose flag).

Handling Errors and the Silent Colon

You probably noticed the \?) case in our example. That’s how getopts tells you the user provided an option you didn’t ask for. But what about when a user provides -a but forgets to give it an argument? getopts handles this, but its behavior is controlled by the first character of the options string.

If the options string starts with a colon (while getopts ":a:bc" opt), getopts goes into “silent” error reporting mode. Instead of printing its own annoying error message, it lets you handle the errors. It will set opt to : and $OPTARG to the missing option letter. This is a best practice. You want to control your script’s error messages.

#!/bin/bash

# Note the leading colon
while getopts ":a:bc" opt; do
  case $opt in
    a)
      echo "Option -a, argument: $OPTARG" >&2
      ;;
    b)
      echo "Option -b" >&2
      ;;
    c)
      echo "Option -c" >&2
      ;;
    :)
      echo "Option -$OPTARG requires an argument." >&2
      exit 1
      ;;
    \?)
      echo "Invalid option: -$OPTARG" >&2
      exit 1
      ;;
  esac
done

Now, if you run ./test.sh -a, you’ll get your clean, custom error message: “Option -a requires an argument.” instead of a generic bash error. Always use the leading colon.

Shifting and Dealing With What’s Left

After getopts has finished its loop, it leaves the non-option arguments untouched. This is your script’s actual payload. But getopts has internally been tracking its position with the $OPTIND variable. To get to your regular arguments, you need to shift them into place.

$OPTIND is the index of the next argument to be processed. Since options are arguments 1, 2, 3…, you need to shift $((OPTIND-1)) to move all the processed options out of the way.

# ... the rest of the getopts while loop above ...

# Shift all the parsed options out of the way
shift $((OPTIND-1))

# Now, $1 is the first non-option argument, $2 is the second, etc.
echo "First non-option argument: $1"

So for the command ./script.sh -a arg -b file1 file2, after parsing -a and -b, shift $((OPTIND-1)) will make $1 become file1 and $2 become file2.

Putting It All Together: A Realistic Example

Let’s write a script that might actually be useful.

#!/bin/bash

usage() {
    echo "Usage: $0 [-v] [-f config_file] [-n lines] <input_file>" 1>&2
    exit 1
}

verbose=false
config="default.conf"
lines=50

# Parse options with silent error handling
while getopts ":vf:n:" opt; do
    case $opt in
        v)
            verbose=true
            echo "Verbose mode enabled." >&2
            ;;
        f)
            config="$OPTARG"
            ;;
        n)
            # Check if the argument is a number - getopts doesn't do this for you!
            if [[ ! "$OPTARG" =~ ^[0-9]+$ ]]; then
                echo "Error: -n requires a numeric argument." >&2
                exit 1
            fi
            lines="$OPTARG"
            ;;
        :)
            echo "Error: Option -$OPTARG requires an argument." >&2
            usage
            ;;
        \?)
            echo "Error: Invalid option -$OPTARG" >&2
            usage
            ;;
    esac
done

shift $((OPTIND-1))

# Check for the mandatory non-option argument
if [[ $# -eq 0 ]]; then
    echo "Error: You must specify an input file." >&2
    usage
fi

input_file=$1

echo "Processing $input_file with config $config, showing $lines lines."
# ... actual script logic would go here ...

This script demonstrates the full lifecycle: parsing, validation, shifting, and checking for mandatory positional arguments. Notice the validation for -ngetopts ensures there’s an argument, but it’s your job to ensure it’s the right kind of argument. Never trust user input. Always validate.