| |

Bash Scripting 05 ๐Ÿง Positional Parameters โ€” Handling command-line arguments ($1, $2, $@)

โฑ๏ธ Estimated read: 11 min | |

Every useful script eventually needs to accept input from the outside world. Hardcoded values work for a demonstration, but real automation demands flexibility: a backup script that receives a target directory, a deployment tool that accepts an environment name, or a log analyzer that takes a filename. The shell provides a direct mechanism for this: positional parameters. When a script is invoked, the arguments following its name are assigned to special variables numbered from one upward. $1 holds the first argument, $2 the second, and the special parameter $@ represents the complete collection. Understanding these parametersโ€”their syntax, their quirks under quoting, and their role in robust argument parsingโ€”separates scripts that work for one specific case from scripts that work for any case.

Key point: Positional parameters are read-only variables assigned by the shell at invocation time. $1 through $9 are accessible directly, but any parameter numbered ten or higher requires braces: ${10}. Always quote "$@" to preserve argument boundaries; unquoted $@ allows word splitting that will silently corrupt arguments containing spaces.


Why positional parameters exist

The fixed-input problem.

A script without parameters is a script with a single behavior. It can only operate on the paths, filenames, and values written into its source code. This limitation forces duplication: if you need to process five different directories, you either copy the script five times or manually edit it between runs. Positional parameters eliminate this duplication by making the script’s input external to its implementation. The same backup script can target /home, /var/www, or /tmp depending on what you pass at the command line, without a single change to the script itself .

The quoting vulnerability problem.

The most common bug in shell scripts that handle arguments is unquoted expansion. When a user runs ./script.sh "my file.txt", the shell passes a single argument containing a space. If the script references $1 without quotes, the shell splits that argument into two words, my and file.txt. The script then processes two files instead of one, or crashes because neither exists. The special parameter "$@" exists specifically to preserve the original argument boundaries: each positional parameter expands as a separate word, with spaces intact . This distinction between quoted and unquoted expansion is not a minor detail; it is the difference between a script that handles real filenames and one that fails unpredictably.

The argument-counting problem.

Scripts must know how many arguments they received before processing them. A script expecting a source and destination should fail clearly if given one argument or five. The special parameter $# provides this count directly, enabling validation before any argument is accessed . Without $#, a script might proceed with empty variables, produce confusing errors, or modify files in unintended ways because a missing parameter expanded to an empty string.


a. Accessing individual parameters

The shell assigns each command-line argument to a numbered parameter starting at $1. The script’s own name is stored in $0, which is not a positional parameter in the strict sense and is not affected by the shift command .

#!/usr/bin/env bash
# Display the first three arguments individually
echo "Script name: $0"          # $0 is the script path, not a positional parameter
echo "First argument: $1"       # The first argument after the script name
echo "Second argument: $2"      # The second argument
echo "Third argument: $3"       # The third argument (empty if not provided)

Running this script with ./show_args.sh alpha beta gamma produces output identifying each argument by position. If the script receives fewer arguments than referenced, the missing variables expand to empty stringsโ€”no error is raised. This silent behavior means scripts must validate $# before assuming an argument exists.

For positions beyond nine, braces become mandatory. The shell interprets $10 as $1 followed by the literal character 0. To access the tenth argument, write ${10} .

#!/usr/bin/env bash
# Accessing double-digit parameters requires braces
echo "Tenth argument: ${10}"    # Correct: expands to the tenth positional parameter
echo "Tenth argument: $10"      # Incorrect: expands $1 then appends literal "0"

This syntax rule exists because the shell’s parameter expansion grammar only recognizes a single digit immediately after $. Braces disambiguate the parameter name from any following text.


b. Collecting all arguments with $@ and $*

Bash provides two special parameters for representing the complete set of positional parameters: $@ and $*. They behave identically when unquoted, but diverge critically inside double quotes. This difference determines whether your script preserves argument boundaries or collapses them.

#!/usr/bin/env bash
# Demonstrate the difference between "$@" and "$*"
printf 'Quoted $@: '; printf '[%s] ' "$@"; echo
printf 'Quoted $*: '; printf '[%s] ' "$*"; echo

When invoked as ./collect.sh "first arg" "second arg", the quoted "$@" expands to two separate words: first arg and second arg. The quoted "$*" expands to a single word: first arg second arg, joined by the first character of the IFS variable (a space by default) .

The practical consequence is significant. "$@" is almost always what you want. It preserves the user’s original intent: each argument remains a distinct entity, even if it contains spaces. "$*" is appropriate only when you deliberately need to join all arguments into a single string, such as logging or constructing a delimited output line.

#!/usr/bin/env bash
# Iterate over arguments safely using "$@"
for arg in "$@"; do
    echo "Processing: $arg"      # Each iteration receives one intact argument
done

Using $@ unquoted in this loop would split arguments containing spaces, causing the loop body to execute more times than the user intended .


c. Validating and manipulating parameters

Robust scripts validate their arguments before use. The parameter $# provides the argument count, and conditional tests can enforce minimums, maximums, or exact counts.

#!/usr/bin/env bash
# Require exactly two arguments: source and destination
if [ "$#" -ne 2 ]; then
    echo "Usage: $0 <source> <destination>" >&2
    exit 1
fi
source="$1"
destination="$2"
echo "Copying from $source to $destination"

The shift builtin removes the first positional parameter and renumbers the remaining ones: $2 becomes $1, $3 becomes $2, and so on. This is essential for parsing options followed by operands, where options are consumed one at a time until none remain .

#!/usr/bin/env bash
# Consume options with a while loop and shift
verbose=false
while [ "$#" -gt 0 ]; do
    case "$1" in
        -v|--verbose)
            verbose=true
            shift                    # Remove the option from the parameter list
            ;;
        --)
            shift                    # End of options marker; stop parsing
            break
            ;;
        *)
            break                    # First non-option argument; stop parsing
            ;;
    esac
done
# Remaining parameters are now operands
echo "Verbose: $verbose, remaining args: $@"

This patternโ€”test $1, act, shift, repeatโ€”is the foundation of manual argument parsing. It handles options in any order and leaves non-option arguments available for further processing. The -- convention explicitly signals the end of options, allowing filenames that begin with dashes to be processed correctly .


Complete Example Session

#!/usr/bin/env bash
# ============================================
# PART 1: THE SCRIPT HEADER AND USAGE
# ============================================
# A file processing script that demonstrates positional parameters
set -euo pipefail

usage() {
    echo "Usage: $0 [-v] [-o OUTPUT] FILE [FILE...]" >&2
    echo "  -v         Verbose output" >&2
    echo "  -o OUTPUT  Write results to OUTPUT" >&2
    exit 1
}

# ============================================
# PART 2: DEFAULTS AND OPTION PARSING
# ============================================
verbose=false
output=""

# Parse options until the first non-option argument
while [ "$#" -gt 0 ]; do
    case "$1" in
        -v)
            verbose=true
            shift
            ;;
        -o)
            if [ -z "${2:-}" ]; then
                echo "Error: -o requires an argument" >&2
                usage
            fi
            output="$2"
            shift 2
            ;;
        --)
            shift
            break
            ;;
        -*)
            echo "Unknown option: $1" >&2
            usage
            ;;
        *)
            break
            ;;
    esac
done

# ============================================
# PART 3: VALIDATE REMAINING OPERANDS
# ============================================
if [ "$#" -eq 0 ]; then
    echo "Error: at least one FILE is required" >&2
    usage
fi

# ============================================
# PART 4: PROCESS EACH REMAINING ARGUMENT
# ============================================
for file in "$@"; do
    if [ ! -f "$file" ]; then
        echo "Skipping non-file: $file" >&2
        continue
    fi
    if $verbose; then
        echo "Processing: $file"
    fi
done

# ============================================
# PART 5: OUTPUT HANDLING
# ============================================
if [ -n "$output" ]; then
    echo "Would write results to: $output"
else
    echo "No output file specified"
fi

The five parts demonstrate the complete lifecycle of argument handling: define usage, parse options with shift, validate the remaining operand count, iterate safely with "$@", and act on parsed values. The quoted "$@" in the loop ensures filenames with spaces are processed as single units.


Quick Reference

Parameter Syntax

SyntaxMeaningWhen to Use
$1โ€“$9First through ninth positional parametersDirect access to individual arguments
${10}Tenth parameter (braces required)Any parameter beyond single digits
$#Number of positional parametersValidation before access
$0Script name (not a positional parameter)Usage messages
"$@"All parameters as separate wordsIteration, forwarding, safety
"$*"All parameters as one wordJoining for logs or display
$@All parameters, subject to splittingRarely correct; avoid

Validation Tests

TestChecksExample
[ "$#" -eq N ]Exact count[ "$#" -eq 2 ]
[ "$#" -ge N ]Minimum count[ "$#" -ge 1 ]
[ -z "${1:-}" ]Argument missing or emptyGuard before use
[ -n "${1:-}" ]Argument present and non-emptyProceed safely

Shift Behavior

CommandEffect
shiftRemove $1; $2 becomes $1, etc.
shift 2Remove first two parameters
$# after shiftDecreases by number of shifts

Best Practices

โœ… Do This:

# Validate count before accessing
if [ "$#" -lt 2 ]; then usage; fi                     # โœ…
# Quote "$@" in for loops
for arg in "$@"; do echo "$arg"; done                  # โœ…
# Use ${10} for double digits
echo "Tenth: ${10}"                                    # โœ…
# Use ${1:-} for safe checks
if [ -z "${1:-}" ]; then echo "missing"; fi            # โœ…
# Shift after consuming options
case "$1" in -v) verbose=true; shift ;; esac            # โœ…
# Use $# to detect missing operands
[ "$#" -gt 0 ] || usage                                # โœ…

โŒ Don’t Do This:

# Access $1 without checking $#
echo "$1"                                              # โŒ
# Unquoted $@ splits on spaces
for arg in $@; do echo "$arg"; done                    # โŒ
# $10 is $1 followed by "0"
echo "Tenth: $10"                                      # โŒ
# Direct $1 in tests can fail with set -u
if [ -z "$1" ]; then echo "missing"; fi                # โŒ
# Forgetting shift causes infinite loop
while [ "$#" -gt 0 ]; do case "$1" in -v) ;; esac; done # โŒ
# Assuming $* preserves boundaries
printf '%s\n' "$*"                                     # โŒ

Common Pitfalls

PitfallWhy It HappensFix
Argument with spaces split into multiple$@ used without quotesUse "$@"
${10} vs $10 confusionShell only reads one digit after $Always brace double digits
set -u crashes on missing argument$1 referenced when unsetUse ${1:-}
Infinite loop in option parserForgetting shift in case branchAdd shift after each option
Empty string treated as missing[ -z "$1" ] when $1 unsetUse [ -z "${1:-}" ]
$# checked after shiftCount changes during parsingValidate before parsing or track remaining
Filename beginning with - rejectedCase pattern -* catches itHandle -- to end options

Real-World Examples

1. Require exactly two arguments

[ "$#" -eq 2 ] || { echo "Usage: $0 SRC DST" >&2; exit 1; }
src="$1"; dst="$2"

2. Safe iteration over files

for file in "$@"; do
    [ -f "$file" ] || continue
    wc -l "$file"
done

3. Shift through options manually

while [ "${1:-}" = "-v" ]; do
    verbose=true
    shift
done

4. Access tenth argument

echo "Tenth: ${10}"

5. Check argument count before use

if [ "$#" -lt 1 ]; then
    echo "Need at least one argument" >&2
    exit 1
fi

6. Join all arguments for logging

echo "Invoked with: $*"

7. Forward all arguments to another command

exec other_script "$@"

8. Handle end-of-options marker

case "$1" in
    --) shift; break ;;
esac

9. Default value for optional argument

target="${1:-.}"
echo "Target: $target"

10. Count operands after option parsing

shift $((OPTIND - 1))
[ "$#" -gt 0 ] || { echo "No operands"; exit 1; }

Visual

The Parameter Assignment Model

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  COMMAND LINE TO POSITIONAL PARAMETERS                       โ”‚
โ”‚                                                              โ”‚
โ”‚  ./backup.sh /home/user "my documents" -v                    โ”‚
โ”‚       โ”‚         โ”‚            โ”‚           โ”‚                   โ”‚
โ”‚       โ”‚         โ”‚            โ”‚           โ””โ”€โ”€ $3 = -v         โ”‚
โ”‚       โ”‚         โ”‚            โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ $2 = "my documents"
โ”‚       โ”‚         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ $1 = /home/user โ”‚
โ”‚       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ $0 = ./backup.shโ”‚
โ”‚                                                              โ”‚
โ”‚  $# = 3        "$@" = 3 separate words                       โ”‚
โ”‚  ${10} requires braces                                       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Quoted vs Unquoted Expansion

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  "$@" vs $@ WITH ARGUMENTS CONTAINING SPACES                 โ”‚
โ”‚                                                              โ”‚
โ”‚  Invocation: ./script.sh "a b" c                             โ”‚
โ”‚                                                              โ”‚
โ”‚  "$@" (quoted):                                              โ”‚
โ”‚    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”                                         โ”‚
โ”‚    โ”‚  "a b"  โ”‚ โ”‚"c"โ”‚   Two words, boundaries preserved       โ”‚
โ”‚    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”˜                                         โ”‚
โ”‚                                                              โ”‚
โ”‚  $@ (unquoted):                                              โ”‚
โ”‚    โ”Œโ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”                                         โ”‚
โ”‚    โ”‚"a"โ”‚ โ”‚"b"โ”‚ โ”‚"c"โ”‚   Three words, space split             โ”‚
โ”‚    โ””โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”˜                                         โ”‚
โ”‚                                                              โ”‚
โ”‚  Rule: Always quote "$@"                                     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Shift and Option Parsing Flow

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  SHIFT-BASED OPTION PARSING                                  โ”‚
โ”‚                                                              โ”‚
โ”‚  Initial: $1=-v  $2=-o  $3=out.txt  $4=file1  $5=file2       โ”‚
โ”‚                                                              โ”‚
โ”‚  Step 1: case "$1" in -v) shift ;; esac                      โ”‚
โ”‚          $1=-o  $2=out.txt  $3=file1  $4=file2              โ”‚
โ”‚                                                              โ”‚
โ”‚  Step 2: case "$1" in -o) output=$2; shift 2 ;; esac         โ”‚
โ”‚          $1=file1  $2=file2                                  โ”‚
โ”‚                                                              โ”‚
โ”‚  Step 3: Non-option encountered; break                       โ”‚
โ”‚          Operands: "$@" = file1 file2                        โ”‚
โ”‚                                                              โ”‚
โ”‚  Each shift renumbers remaining parameters downward.         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
$1โ€“$9First through ninth positional parameters; direct syntax
${10}+Braces required for parameters with multiple digits
$0Script name; not a positional parameter; unaffected by shift
$#Count of positional parameters; validates before access
"$@"All parameters as separate words; the correct choice for iteration
"$*"All parameters as one word joined by IFS; use only for display
shiftRemoves $1 and renumbers; the engine of option parsing
--Conventional marker signaling end of options
${1:-}Safe expansion preventing errors under set -u
$@ unquotedSubject to word splitting; almost always a bug

Key takeaways:

  • Quote "$@" religiously. Every unquoted expansion of $@ is a latent bug that manifests when a user passes a filename containing a space.
  • Validate $# first. Accessing $1 without confirming at least one argument exists produces silent empty strings or crashes under set -u.
  • Brace double digits. $10 is not the tenth argument; ${10} is. The shell’s grammar reads only one character after $.
  • Shift consumes and renumbers. Option parsing is a loop of test, act, shift. Forgetting the shift produces an infinite loop.
  • $* joins, $@ separates. The distinction only matters when quoted, and that is precisely when it matters most.
  • ${1:-} is safer than $1. The colon-dash form provides a default and prevents unset-variable errors.
  • Handle -- explicitly. Users have legitimate reasons to pass arguments beginning with dashes; the parser must respect the end-of-options marker.

Remember: Positional parameters are the shell’s native interface between the command line and the script. They are assigned at invocation, read-only during execution, and subject to subtle expansion rules that reward precision. The script that quotes "$@", validates $#, and shifts deliberately will handle any input a user provides. The script that does not will fail the first time a path contains a space and mystify its author in the process.


Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!