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:
$0— the name of the script itself.$#— the total number of arguments passed.$@— all arguments as separate words (preferred in almost all cases).$*— all arguments as a single combined string (rarely what you want).
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":
v— a flag with no argument (-v), just toggles verbose mode on.o:— the colon afteromeans this flag requires an argument (-o filename.txt), which becomes available inside the loop as$OPTARG.h— another simple flag with no argument, used here to print help text.
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:
- The
while [[ $# -gt 0 ]]loop continues as long as there are arguments left to process. --output)expects the value as a separate argument (--output file.txt), so it reads$2and shifts by 2 to consume both the flag and its value.--output=*)handles the equals-sign style (--output=file.txt). The parameter expansion${1#*=}strips everything up to and including the first=, extracting just the value.- The final
*)case catches anything unrecognized and exits with an error, which prevents silent misconfiguration from typos.
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
- Always provide a
-h/--helpoption that explains usage — future you (or a teammate) will thank you. - Validate required arguments early and exit with a clear error message if they’re missing.
- Use
"$@"instead of$*whenever forwarding or iterating over arguments, to correctly preserve arguments containing spaces. - Support both short (
-v) and long (--verbose) flags for scripts meant for broader use, since different users have different preferences. - Keep argument parsing logic near the top of the script, clearly separated from the main logic that follows.
Security Considerations
- Never pass unvalidated arguments directly into
evalor other commands that interpret strings as code. - Be cautious with arguments used to build file paths — validate them to prevent path traversal (e.g., a
--file=../../etc/passwdstyle attack) if the script has elevated privileges. - Quote all variable expansions of parsed arguments (
"$output", not$output) to avoid word-splitting issues, especially with filenames containing spaces.
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
- Using
$*instead of$@when iterating over arguments, breaking on any argument containing spaces. - Forgetting to
shiftafter processing a flag, causing an infinite loop or repeated processing of the same argument. - Not validating that required positional arguments were actually provided before using them.
- Mixing up
getopts(built-in, POSIX-compliant, single-character flags only) with the externalgetoptcommand (supports long options but behaves differently across systems).
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.