How to Parse Command Line Arguments in Bash

How to Parse Command Line Arguments in Bash

How to Parse Command Line Arguments in Bash

Every useful Bash script eventually grows beyond a single fixed behavior, and that’s when command-line arguments become essential. I remember writing my first “real” script — a backup tool — and hardcoding the source and destination paths directly into the file. Every time I wanted to back up a different folder, I had to edit the script itself. It didn’t take long to realize that properly parsing arguments turns a one-off script into a genuinely reusable tool.

This guide covers every major technique for handling command-line arguments in Bash, from simple positional parameters to full-featured flag parsing with getopts and beyond.

Positional Parameters: The Basics

The simplest way to accept input is through positional parameters — $1, $2, $3, and so on, representing the first, second, and third arguments passed to the script.

#!/bin/bash
echo "First argument: $1"
echo "Second argument: $2"
echo "Total arguments: $#"
echo "All arguments: $@"

Running this:

./script.sh hello world

Produces:

First argument: hello
Second argument: world
Total arguments: 2
All arguments: hello world

A few special variables are worth memorizing:

The difference between $@ and $* matters a lot when quoting. "$@" expands each argument as a separate quoted word, preserving arguments with spaces correctly, while "$*" joins everything into one string. Always prefer "$@" when looping through arguments.

for arg in "$@"; do
  echo "Arg: $arg"
done

Checking If Arguments Were Provided

Before using positional parameters, it’s good practice to check that they were actually supplied:

if [ "$#" -lt 2 ]; then
  echo "Usage: $0 <source> <destination>" >&2
  exit 1
fi

source="$1"
destination="$2"

This prevents your script from running with missing or empty values and silently doing the wrong thing.

Parsing Flags with getopts

Positional parameters work fine for simple scripts, but once you want named flags like -v for verbose or -f filename for a specific file, getopts is the standard built-in tool for the job.

#!/bin/bash

verbose=false
output="output.txt"

while getopts "vo:h" opt; do
  case "$opt" in
    v)
      verbose=true
      ;;
    o)
      output="$OPTARG"
      ;;
    h)
      echo "Usage: $0 [-v] [-o output_file]"
      exit 0
      ;;
    \?)
      echo "Invalid option: -$OPTARG" >&2
      exit 1
      ;;
  esac
done

echo "Verbose: $verbose"
echo "Output file: $output"

Let’s break down the syntax "vo:h":

Run it like this:

./script.sh -v -o results.txt

Output:

Verbose: true
Output file: results.txt

The \?) case catches any option not explicitly defined, letting you handle invalid flags gracefully rather than letting the script fail confusingly.

Handling Remaining Positional Arguments After Flags

Often you want to support both flags and positional arguments together, like script.sh -v file1.txt file2.txt. After getopts finishes processing flags, use shift with the special $OPTIND variable to move past them:

shift $((OPTIND - 1))

echo "Remaining arguments: $@"

$OPTIND tracks the index of the next argument getopts would process. Subtracting 1 and using shift removes all the flag arguments already consumed, leaving only the plain positional arguments in $@.

Parsing Long Options (--verbose, --output=file.txt)

getopts only supports single-character flags natively. For GNU-style long options, you need to write your own parsing loop:

#!/bin/bash

verbose=false
output="output.txt"

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

echo "Verbose: $verbose"
echo "Output: $output"

Here’s what’s happening step by step:

This pattern — manually looping through $@ with a case statement — is the standard way to build flexible, GNU-style argument parsing in pure Bash without external dependencies.

Combining Flags and Positional Arguments

A realistic script often needs both. Here’s a more complete example modeled after a simple backup tool:

#!/bin/bash
set -euo pipefail

verbose=false
compress=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    -v|--verbose)
      verbose=true
      shift
      ;;
    -c|--compress)
      compress=true
      shift
      ;;
    -h|--help)
      echo "Usage: $0 [-v] [-c] <source> <destination>"
      exit 0
      ;;
    --)
      shift
      break
      ;;
    -*)
      echo "Unknown option: $1" >&2
      exit 1
      ;;
    *)
      break
      ;;
  esac
done

source="${1:?Error: source directory required}"
destination="${2:?Error: destination directory required}"

echo "Backing up from $source to $destination"
echo "Verbose: $verbose | Compress: $compress"

The --) case handles the common Unix convention where -- signals “everything after this is a positional argument, not a flag” — useful if a filename itself happens to start with a dash. The ${1:?message} syntax provides a clear error message if a required positional argument is missing, rather than proceeding with an empty value.

Real-World Use Cases

1. Backup and deployment scripts. Supporting flags like --dry-run, --verbose, and --force alongside positional source/destination paths makes a script genuinely reusable across different scenarios.

2. CLI tools distributed to a team. Any internal tool meant for more than one person benefits enormously from a proper --help flag and consistent option parsing, since it removes the need to read the script’s source code to understand how to use it.

3. Wrapper scripts around other tools. A script that wraps rsync, docker, or ffmpeg often needs to accept and forward specific flags while adding its own custom logic on top.

4. Automated testing and CI scripts. Supporting a --env=staging or --env=production flag lets the same script run in multiple environments without duplicating logic.

Best Practices

Security Considerations

Troubleshooting Common Issues

getopts stops after the first flag with an argument: Make sure you’re using $OPTARG correctly and that your option string has a colon (:) after any flag expecting a value.

Long options like --verbose aren’t recognized: getopts doesn’t support long options natively — you’ll need the manual while/case loop pattern shown above.

Script doesn’t see arguments after --: This is expected behavior if you’re using -- as a separator; make sure your parsing loop explicitly handles the --) case and shifts past it.

Positional arguments are empty even though you passed them: Double check you haven’t consumed too many arguments with shift inside your flag-parsing loop — an off-by-one shift count is a very common bug.

Common Mistakes to Avoid

FAQs

Q: What’s the difference between getopts and getopt? getopts is a Bash built-in that only handles single-character flags but is portable and always available. getopt is an external command that supports long options but varies in behavior between systems (GNU vs. BSD), making it less portable.

Q: How do I support both -o value and -o=value syntax? getopts naturally supports -o value (space-separated). For the = style, you’d need custom parsing similar to the --output=* example shown above, adapted for short flags.

Q: Can I mix flags and positional arguments in any order? Yes, but it requires careful parsing logic, typically breaking out of the flag-parsing loop once a non-flag argument is encountered, as shown in the combined example above.

Q: How do I access all arguments after a certain point? Use shift N to remove the first N arguments, then "$@" will contain everything remaining.

Summary

Parsing command-line arguments well is what separates a quick one-off script from a genuinely reusable tool. Start with positional parameters for simple cases, move to getopts once you need named flags, and build a manual case-based parser when you need long-option support. Whichever approach you choose, validating required inputs and providing clear usage messages will make your scripts far more pleasant for anyone (including future you) to actually use.

References

Exit mobile version