Mastering Positional Parameters: A Guide to Bash Script Arguments
Unlock the power of dynamic Bash scripts by mastering positional parameters. This comprehensive guide explains how to access command-line arguments using `$1`, `$2`, and special variables like `$#` (argument count) and the crucial `"$@"` (all arguments). Learn essential best practices for input validation, understand the difference between `\$*` and `\$@`, and see practical examples for writing robust, error-checked scripts that adapt flawlessly to user input.
Mastering Positional Parameters: A Guide to Bash Script Arguments
Bash scripts become much more useful when they accept arguments instead of making you edit variables inside the file. A backup script should accept a source directory. A deploy script should accept an environment name. A cleanup script should accept one or more paths. Those values arrive as positional parameters: $1, $2, $3, and so on.
The tricky part is not reading $1. The tricky part is handling missing arguments, arguments with spaces, optional flags, and the moment your script grows from "just for me" into something another person will run at 2 a.m.
The Anatomy of Positional Parameters
Positional parameters are special variables defined by the shell that correspond to the words provided on the command line following the script name. They are numbered sequentially, starting from 1.
| Parameter | Description | Example Value (when running ./script.sh file1 dir/) |
|---|---|---|
$0 |
The name of the script itself (or function). | ./script.sh |
$1 |
The first argument passed to the script. | file1 |
$2 |
The second argument passed to the script. | dir/ |
$N |
The Nth argument (where N > 0). | |
${10} |
Arguments beyond 9 must be enclosed in curly braces. |
Accessing Arguments Beyond $9
While arguments 1 through 9 are accessed directly as $1 through $9, accessing the tenth argument and subsequent arguments requires enclosing the number in braces to prevent ambiguity with environment variables or string operations (e.g., ${10} instead of $10).
Essential Special Parameters for Scripting
Beyond the numerical parameters, Bash provides several critical special variables that relate to the argument set as a whole. These are indispensable for validation and iteration.
Counting Arguments with $#
The special variable $# holds the total number of command-line arguments passed to the script (excluding $0). This is perhaps the most important variable for implementing input validation.
#!/bin/bash
if [ "$#" -eq 0 ]; then
echo "Error: No arguments provided."
echo "Usage: $0 <input_file>"
exit 1
fi
echo "You provided $# arguments."
All Arguments: $@ and $*
The variables $@ and $* both represent the full list of arguments, but they behave differently—especially when quoted.
$* (Single String)
When double-quoted ("$*"), the entire list of positional parameters is treated as a single argument, separated by the first character of the IFS (Internal Field Separator) variable (usually a space).
- If input arguments are:
arg1arg2arg3 "$*"expands to:"arg1 arg2 arg3"(one single element)
$@ (Separate Strings - Preferred)
When double-quoted ("$@"), each positional parameter is treated as a separate, quoted argument. This is the standard and preferred method for iterating over arguments, as it correctly preserves arguments containing spaces.
- If input arguments are:
arg1"arg with space"arg3 "$@"expands to:"arg1" "arg with space" "arg3"(three distinct elements)
Why Quoting Matters: A Demonstration
Consider a script run with arguments: ./test.sh 'hello world' file.txt
#!/bin/bash
# Unquoted $* splits on spaces and is usually wrong.
echo "-- Looping using unquoted \$* --"
for item in $*; do
echo "Item: $item"
done
# Quoted "$@" preserves each original argument.
echo "-- Looping using quoted \$@ --"
for item in "$@"; do
echo "Item: $item"
done
With ./test.sh 'hello world' file.txt, the unquoted loop prints hello and world as separate items. The "$@" loop keeps hello world as one argument. That difference is the reason experienced shell users reach for "$@" almost automatically.
Practical Techniques for Argument Handling
1. Basic Argument Retrieval Script
This simple script demonstrates how to access specific parameters and use $0 to provide helpful feedback.
deploy_service.sh:
#!/bin/bash
# Usage: deploy_service.sh <service_name> <environment>
SERVICE_NAME="$1"
ENVIRONMENT="$2"
# Validation check (minimum two arguments)
if [ "$#" -lt 2 ]; then
echo "Usage: $0 <service_name> <environment>"
exit 1
fi
echo "Starting deployment for service: $SERVICE_NAME"
echo "Target environment: $ENVIRONMENT"
# Run command using the validated parameters
ssh admin@server-"$ENVIRONMENT" "/path/to/start $SERVICE_NAME"
2. Robust Input Validation
Good scripts always validate input before proceeding. This includes checking the count ($#) and often checking the content of the arguments (e.g., checking if an argument is a number or a valid file path).
#!/bin/bash
# 1. Check Argument Count (Must be exactly 3)
if [ "$#" -ne 3 ]; then
echo "Error: This script requires three arguments (source, destination, user)."
echo "Usage: $0 <src_path> <dest_path> <user>"
exit 1
fi
SRC_PATH="$1"
DEST_PATH="$2"
USER="$3"
# 2. Check Content (Example: Verify source path exists)
if [ ! -f "$SRC_PATH" ]; then
echo "Error: Source file '$SRC_PATH' not found or is not a file."
exit 2
fi
# If validation passes, proceed
echo "Copying $SRC_PATH to $DEST_PATH as user $USER..."
Best Practice Tip: Always provide a clear, concise
Usage:statement when validation fails. This helps users quickly fix their command invocation.
3. Iterating Arguments with shift
The shift command is an excellent tool for processing arguments sequentially, often used when handling simple flags or when processing arguments one by one inside a while loop.
shift discards the current $1 argument, moves $2 to $1, $3 to $2, and decrements $# by one. This allows you to process the first argument and then loop until no arguments remain.
#!/bin/bash
# Process a simple -v flag and then list the remaining files
VERBOSE=false
if [ "$1" = "-v" ]; then
VERBOSE=true
shift # Discard the -v flag and shift arguments up
fi
if $VERBOSE; then
echo "Verbose mode enabled."
fi
if [ "$#" -eq 0 ]; then
echo "No files specified."
exit 0
fi
echo "Processing $# remaining files:"
for file in "$@"; do
if $VERBOSE; then
echo "Checking file: $file"
fi
# ... processing logic here
done
Note:
shiftis useful for simple parsing. For complex scripts with many flags,getoptsis usually a better fit for short options. Long-option handling varies by platform, so test carefully if you use externalgetopt.
A More Realistic Parser
Many internal scripts start with one optional flag and one required value. Here is a small pattern that stays readable:
#!/usr/bin/env bash
set -u
dry_run=false
environment=""
usage() {
echo "Usage: $0 [--dry-run] --env <dev|staging|prod> <file>..." >&2
}
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
dry_run=true
shift
;;
--env)
if [ "$#" -lt 2 ]; then
echo "Error: --env requires a value." >&2
usage
exit 2
fi
environment="$2"
shift 2
;;
--help|-h)
usage
exit 0
;;
--)
shift
break
;;
-*)
echo "Error: unknown option: $1" >&2
usage
exit 2
;;
*)
break
;;
esac
done
if [ -z "$environment" ]; then
echo "Error: --env is required." >&2
usage
exit 2
fi
if [ "$#" -eq 0 ]; then
echo "Error: provide at least one file." >&2
usage
exit 2
fi
for file in "$@"; do
if [ ! -f "$file" ]; then
echo "Error: file not found: $file" >&2
exit 3
fi
if $dry_run; then
echo "Would upload $file to $environment"
else
echo "Uploading $file to $environment"
# upload command goes here
fi
done
Notice the boring details. Error messages go to stderr. -- means "stop parsing options," which lets someone pass a filename that starts with a dash. The final file loop uses "$@", so release notes.txt stays one filename.
Common Mistakes
The most common mistake is forgetting quotes:
cp $1 $2
That breaks when either path contains spaces or shell glob characters. Use:
cp -- "$1" "$2"
The -- tells many commands that option parsing is done, which helps if a path starts with -.
Another common mistake is validating too late. If your script expects two arguments, check that before doing anything destructive:
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <source> <destination>" >&2
exit 2
fi
Use distinct exit codes when it helps the caller. A usage error might be 2; a missing file might be 3; a failed external command can keep its own status. You do not need a giant exit-code taxonomy, but returning 0 after a bad invocation makes automation harder to trust.
Functions Have Positional Parameters Too
Inside a Bash function, $1 and $2 refer to the function's arguments, not the script's original arguments.
log_copy() {
local src="$1"
local dest="$2"
echo "Copying $src to $dest"
cp -- "$src" "$dest"
}
log_copy "$1" "$2"
That is useful, but it can surprise you if you expected $1 inside the function to mean the script-level first argument. Pass values explicitly. It makes the function easier to test and easier to reuse.
Forwarding Arguments to Another Command
Many wrapper scripts exist only to add a little setup before calling another command. In that case, "$@" is what keeps the wrapper honest.
#!/usr/bin/env bash
set -e
export APP_ENV=staging
exec /usr/local/bin/myapp "$@"
If someone runs:
./run-staging.sh --config "config with spaces.yml" --verbose
the wrapped command receives the same three arguments. If you used $* or unquoted $@, the config path could be split into several words.
exec is optional, but it is often useful in wrappers because it replaces the shell process with the target process. That makes signals behave more predictably under systemd, Docker, or a process supervisor.
Defaults Without Surprises
Sometimes an argument should be optional. Bash parameter expansion can help:
environment="${1:-dev}"
That means "use $1 if it is set and non-empty; otherwise use dev." This is fine for friendly local scripts, but be careful with production scripts. A silent default can deploy to the wrong environment if someone forgets an argument.
For risky commands, prefer explicit input:
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <environment>" >&2
exit 2
fi
Defaults are best when the consequence is small, such as defaulting a log level or output directory. They are risky when the argument chooses a server, deletes data, or changes a deployment target.
Positional Parameters and set -u
Many Bash scripts use set -u so that unset variables cause an error. That can catch typos, but it also changes how missing positional parameters behave.
#!/usr/bin/env bash
set -u
echo "First argument: $1"
Run that script with no arguments and Bash exits with an "unbound variable" error. That error is technically correct, but it is not friendly. Validate $# before reading required parameters:
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <input-file>" >&2
exit 2
fi
input_file="$1"
For optional parameters under set -u, use a guarded expansion:
mode="${2:-default}"
That keeps strict mode useful without making missing optional values crash the script.
When Positional Parameters Are the Wrong Interface
Positional parameters are great for small commands:
backup.sh /var/www /backup/www.tar.gz
They become hard to read when the script takes many values:
deploy.sh prod us-east-1 api v2.4.1 true false 30
Nobody wants to remember what the fifth argument means. Once a script reaches that point, use named flags or a config file:
deploy.sh --env prod --region us-east-1 --service api --version v2.4.1 --timeout 30
The code is slightly longer, but the command line becomes self-documenting. That is a good trade for scripts used by a team.
Good positional-parameter handling is mostly discipline: validate early, quote every expansion unless you intentionally want splitting, use "$@" for forwarding arguments, and keep usage messages close to the checks that trigger them. Those habits make small scripts survive real filenames, real users, and real automation.