Every reusable script I write eventually needs to accept some kind of input from the person or process running it, rather than having values hardcoded inside. Bash makes this straightforward through positional parameters, but there’s a lot more depth here than most tutorials cover — from handling optional flags to validating input properly. In this article, I’ll go through the full picture of how argument passing works in Bash, starting with the basics and building up to patterns you’d actually use in production scripts.
The Basics: Positional Parameters
When you run a script like this:
./myscript.sh hello world
Bash automatically makes the arguments available inside the script as $1, $2, and so on:
#!/bin/bash
echo "First argument: $1"
echo "Second argument: $2"
Running this would output:
First argument: hello
Second argument: world
Special Variables Related to Arguments
Bash provides a handful of built-in variables that make working with arguments easier:
$0— the name of the script itself$#— the total number of arguments passed$@— all arguments as separate words$*— all arguments as a single combined string$1,$2, … — individual positional arguments
#!/bin/bash
echo "Script name: $0"
echo "Number of arguments: $#"
echo "All arguments: $@"
The Difference Between $@ and $*
This distinction matters more than it might seem at first. When quoted:
"$@" # Expands to "$1" "$2" "$3" ... — each argument stays a separate word
"$*" # Expands to "$1 $2 $3 ..." — all arguments joined into a single string
If you’re looping through arguments and any of them might contain spaces, "$@" is almost always the correct choice:
for arg in "$@"; do
echo "Argument: $arg"
done
Checking If Enough Arguments Were Provided
#!/bin/bash
if [ "$#" -lt 2 ]; then
echo "Usage: $0 <source> <destination>"
exit 1
fi
echo "Copying from $1 to $2"
This is a pattern I use in nearly every script that takes required arguments — fail early with a clear usage message rather than letting the script proceed with missing data.
Providing Default Values for Missing Arguments
#!/bin/bash
name="${1:-World}"
echo "Hello, $name!"
Here, ${1:-World} means “use $1 if it’s set and non-empty, otherwise use World as a default.” Running the script with no arguments prints “Hello, World!”, while running it with an argument like ./script.sh Alex prints “Hello, Alex!”.
Shifting Arguments
The shift command moves each positional parameter down by one, which is useful when processing arguments one at a time in a loop:
#!/bin/bash
while [ "$#" -gt 0 ]; do
echo "Processing: $1"
shift
done
This loop continues until there are no arguments left, printing and discarding one at a time.
Parsing Named Flags and Options
Real-world scripts often accept flags like --name=value or -n value rather than just positional arguments. A common pattern uses a while loop combined with case:
#!/bin/bash
while [ "$#" -gt 0 ]; do
case "$1" in
--name)
name="$2"
shift 2
;;
--verbose)
verbose=true
shift
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
echo "Name: $name"
echo "Verbose: $verbose"
Running ./script.sh --name Alex --verbose would set name to “Alex” and verbose to “true”.
Using getopts for Standard Option Parsing
For single-character flags, Bash’s built-in getopts handles a lot of the boilerplate for you:
#!/bin/bash
while getopts "n:v" opt; do
case "$opt" in
n) name="$OPTARG" ;;
v) verbose=true ;;
*) echo "Usage: $0 [-n name] [-v]"; exit 1 ;;
esac
done
echo "Name: $name"
echo "Verbose: $verbose"
Here, -n expects a value (indicated by the colon after it in "n:v"), while -v is a simple flag with no accompanying value. You’d run this as ./script.sh -n Alex -v.
Handling Remaining Arguments After Options
After using getopts, the special variable $OPTIND tells you how many arguments were consumed by option parsing, which you can use with shift to get to the remaining positional arguments:
#!/bin/bash
while getopts "n:" opt; do
case "$opt" in
n) name="$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
echo "Name: $name"
echo "Remaining arguments: $@"
How Argument Passing Works Internally
When you execute a script, the shell that launches it (whether that’s your interactive shell or another script calling it) uses the execve() system call, passing the script’s path along with an array of argument strings. Bash receives this array and populates $1 through $9 (and beyond, accessible via ${10}, ${11}, etc.) along with $#, $@, and $* based on it. This is fundamentally the same mechanism every program on a Unix-like system uses to receive command-line arguments — Bash just wraps it in convenient variable names. The distinction between $@ and $* comes down to how Bash’s word-splitting rules apply during expansion, which is a shell-level behavior rather than something the underlying execve() call is aware of.
Real-World Use Cases
A backup script that accepts a source and destination:
#!/bin/bash
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <source_dir> <backup_dir>"
exit 1
fi
source_dir="$1"
backup_dir="$2"
mkdir -p "$backup_dir"
cp -r "$source_dir"/* "$backup_dir"
echo "Backup complete."
A deployment script with an optional environment flag:
#!/bin/bash
environment="${1:-staging}"
echo "Deploying to $environment environment..."
A script that accepts multiple filenames to process:
#!/bin/bash
if [ "$#" -eq 0 ]; then
echo "No files provided."
exit 1
fi
for file in "$@"; do
echo "Processing $file..."
done
Automation Example: Flexible Deployment Script
#!/bin/bash
environment="staging"
dry_run=false
while [ "$#" -gt 0 ]; do
case "$1" in
--env)
environment="$2"
shift 2
;;
--dry-run)
dry_run=true
shift
;;
-h|--help)
echo "Usage: $0 [--env <environment>] [--dry-run]"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
echo "Environment: $environment"
if $dry_run; then
echo "Dry run mode - no changes will be made."
else
echo "Deploying for real..."
fi
This combines named flags, default values, and a help option into a script that’s flexible enough for real deployment automation.
Best Practices
- Always validate the number of arguments (
$#) before using them, and print a usage message when requirements aren’t met. - Quote
"$@"when passing arguments through to another command or looping over them, to preserve arguments containing spaces. - Provide sensible defaults using
${var:-default}syntax where it makes sense, rather than forcing every argument to be required. - Use
getoptsfor simple single-character flags, and a manualcase-based loop for more complex long-option parsing. - Include a
-hor--helpoption in any script meant to be reused by others (or future you).
Security Considerations
- Never directly pass unvalidated arguments into commands like
evalor into SQL/API calls without proper escaping, as this opens the door to injection attacks. - Be cautious with arguments used to build file paths — validate against directory traversal patterns like
../if the script has elevated permissions. - If a script accepts a filename argument that will be deleted or overwritten, double-check it against a whitelist or expected pattern before performing the destructive operation.
Optimization Tips
- For scripts with many possible flags,
getoptsis generally more efficient and less error-prone than a hand-rolledcasestatement, since it handles option bundling (like-abcas-a -b -c) automatically. - Avoid unnecessary reprocessing of
$@in large loops; capture it once into an array if you need to reference it multiple times.
Troubleshooting
Arguments with spaces get split unexpectedly: This almost always means you forgot to quote a variable, like using $1 instead of "$1", or $@ instead of "$@".
getopts stops parsing after the first unrecognized option: This is expected — getopts isn’t designed to skip over unknown flags silently. Handle the *) case in your case statement in the getopts loop to give a clear error message.
Script behaves differently in a cron job than in the terminal: Cron typically doesn’t run scripts with an interactive shell environment, so relative paths and some environment variables might not be what you expect. Use absolute paths for anything argument-related that depends on the current directory.
Common Mistakes
- Forgetting to check
$#before accessing$1,$2, etc., leading to unset variable warnings or unexpected blank values. - Confusing
$@and$*, especially in loops where the distinction matters for arguments containing spaces. - Not quoting arguments when passing them to another command, causing word-splitting or glob expansion.
- Mixing up
shift(no number) withshift N, leading to the wrong number of arguments being skipped.
FAQs
How do I access more than 9 positional arguments? Use curly braces: ${10}, ${11}, and so on. Without the braces, $10 would be interpreted as $1 followed by a literal 0.
What’s the difference between getopts and manually parsing with a case statement? getopts is built for short, single-character flags (like -n value) and handles some edge cases automatically, while a manual case-based loop gives you more flexibility for long-form options (like --name value).
How can I make an argument required? Check for it explicitly and exit with an error message if it’s missing, e.g., if [ -z "$1" ]; then echo "Argument required"; exit 1; fi.
Can I pass an array as an argument to a script? Not directly as a single argument — arrays don’t survive being passed between separate processes. Instead, pass the array’s elements as multiple separate arguments and reconstruct the array inside the script using "$@".
Summary
Passing arguments to a Bash script is straightforward at the surface level — $1, $2, and $# will get you most of the way there — but building genuinely robust scripts means understanding the difference between $@ and $*, validating argument counts, providing defaults, and choosing the right approach (manual parsing or getopts) for flags and options. Once these patterns are part of your toolkit, writing scripts that behave predictably no matter how they’re called becomes second nature.
References
- Bash Reference Manual (Positional Parameters): https://www.gnu.org/software/bash/manual/bash.html#Positional-Parameters
- Bash Reference Manual (Bourne Shell Builtins – getopts): https://www.gnu.org/software/bash/manual/bash.html#Bourne-Shell-Builtins
- GNU Bash Manual: https://www.gnu.org/software/bash/manual/bash.html
