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: arg1 arg2 arg3
  • "$*" 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: shift is useful for simple parsing. For complex scripts with many flags, getopts is usually a better fit for short options. Long-option handling varies by platform, so test carefully if you use external getopt.

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.