Shell by Example: Command Line Arguments POSIX + Bash

Command-line arguments make scripts flexible and reusable. Instead of editing a script to change a filename or value, you can pass different values when running it:

./backup.sh /home/user /backups ./backup.sh /var/log /backups

The same script works with different inputs. For these examples, we use set -- to simulate arguments as if the script were called with: ./script hello world foo bar

Edit
#!/bin/sh
set -- hello world foo bar
echo "Arguments set to: $@"
Output:
Arguments set to: hello world foo bar

The shell provides special variables for accessing arguments:

  • $0 is the script name (useful for usage messages)
  • $1, $2, etc. are positional parameters (the arguments)
  • $# is the total count of arguments passed

Assigning positional parameters to named variables makes your code more readable and self-documenting.

Edit
#!/bin/sh
set -- hello world foo bar

echo "Script name (\$0): $0"
echo ""

echo "Positional parameters:"
echo "  First argument (\$1): $1"
echo "  Second argument (\$2): $2"
echo "  Third argument (\$3): $3"
echo ""

echo "Argument count (\$#): $#"
echo ""

# Good practice: assign to named variables for clarity
input_file="$1"
output_file="$2"
echo "Named variables: input=$input_file, output=$output_file"
Output:
Script name ($0): /script

Positional parameters:
  First argument ($1): hello
  Second argument ($2): world
  Third argument ($3): foo

Argument count ($#): 4

Named variables: input=hello, output=world

When you need all arguments at once, use $@ or $*:

  • $@ expands to all arguments as separate words
  • $* expands to all arguments as a single string

Use $@ when you want to iterate over arguments or pass them to another command. Use $* when you need a single string representation (rare in practice).

Edit
#!/bin/sh
show_difference() {
    echo "With \$@ (separate words in a for loop):"
    for arg in "$@"; do
        echo "  - $arg"
    done

    echo ""
    echo "With \$* (single string):"
    echo "  $*"
}

show_difference apple banana cherry
Output:
With $@ (separate words in a for loop):
  - apple
  - banana
  - cherry

With $* (single string):
  apple banana cherry

Quoting matters when arguments contain spaces. Always use "$@" (quoted) to preserve argument boundaries. Unquoted $@ or $* will break arguments that contain spaces into multiple separate arguments.

Edit
#!/bin/sh
show_args() {
    echo "Received $# arguments:"
    for arg in "$@"; do
        echo "  '$arg'"
    done
}

set -- "hello world" "foo bar"

echo "With quoted \"\$@\" (correct):"
show_args "$@"

echo ""
echo "With quoted \"\$*\" (becomes one argument):"
for arg in "$*"; do
    echo "  [$arg]"
done

echo ""
echo "With unquoted \$@ (breaks on spaces - wrong):"
show_args $@

echo ""
echo "Rule: Always use \"\$@\" when passing arguments."
Output:
With quoted "$@" (correct):
Received 2 arguments:
  'hello world'
  'foo bar'

With quoted "$*" (becomes one argument):
  [hello world foo bar]

With unquoted $@ (breaks on spaces - wrong):
Received 4 arguments:
  'hello'
  'world'
  'foo'
  'bar'

Rule: Always use "$@" when passing arguments.

The shift command removes the first argument and shifts all remaining arguments down by one position. This is useful when processing arguments one at a time, especially in loops or after handling an option that takes a value.

shift N removes the first N arguments at once.

Edit
#!/bin/sh
set -- first second third fourth
echo "Before shift: \$1=$1 \$2=$2 \$3=$3 \$4=$4 (\$#=$#)"

shift
echo "After shift:  \$1=$1 \$2=$2 \$3=$3 (\$#=$#)"

shift 2
echo "After shift 2: \$1=$1 (\$#=$#)"
Output:
Before shift: $1=first $2=second $3=third $4=fourth ($#=4)
After shift:  $1=second $2=third $3=fourth ($#=3)
After shift 2: $1=fourth ($#=1)

A common pattern combines while and shift to process a variable number of arguments. The loop runs as long as arguments remain, handling each $1 then shifting it away. This works well for scripts that accept any number of files.

Edit
#!/bin/sh
set -- file1.txt file2.txt file3.txt

echo "Processing files:"
while [ $# -gt 0 ]; do
    echo "  Processing: $1"
    shift
done

echo "All files processed (remaining args: $#)"
Output:
Processing files:
  Processing: file1.txt
  Processing: file2.txt
  Processing: file3.txt
All files processed (remaining args: 0)

Scripts should validate that they received the expected arguments. Use $# to check the argument count:

  • $# -eq 0 means no arguments were provided
  • $# -ne N means the count doesn’t equal N (exactly N expected)
  • $# -lt N means fewer than N arguments (at least N expected)
Edit
#!/bin/sh
check_any_args() {
    if [ $# -eq 0 ]; then
        echo "Error: No arguments provided"
        echo "Usage: command <arg>..."
        return 1
    fi
    echo "Got $# argument(s): $*"
}

check_exactly_two() {
    if [ $# -ne 2 ]; then
        echo "Error: Expected exactly 2 arguments, got $#"
        echo "Usage: command <source> <destination>"
        return 1
    fi
    echo "Got exactly two: $1 and $2"
}

echo "Testing check_any_args:"
check_any_args
check_any_args one two

echo ""
echo "Testing check_exactly_two:"
check_exactly_two one
check_exactly_two one two
Output:
Testing check_any_args:
Error: No arguments provided
Usage: command <arg>...
Got 2 argument(s): one two

Testing check_exactly_two:
Error: Expected exactly 2 arguments, got 1
Usage: command <source> <destination>
Got exactly two: one and two

Default values for missing arguments:

  • ${var:-default} uses default if var is unset or empty
  • ${var:=default} also assigns the default to var
Edit
#!/bin/sh
# Using :- (doesn't modify the variable)
unset name
echo "Hello, ${name:-Guest}"
echo "name is still: '${name}'"

# Using := (assigns the default)
unset name
echo "Hello, ${name:=Guest}"
echo "name is now: '${name}'"

# Common use: default argument values
set -- "customvalue"
name="${1:-default_name}"
port="${2:-8080}"
echo "Name: $name, Port: $port"
Output:
Hello, Guest
name is still: ''
Hello, Guest
name is now: 'Guest'
Name: customvalue, Port: 8080

The ${var:?message} syntax exits with an error if the variable is unset or empty. This provides a concise way to require arguments without writing explicit if-statements.

The : (colon) command discards the expansion’s value since we only want the side effect of triggering the error.

Edit
#!/bin/sh
require_args() {
    : "${1:?Error: First argument (source file) is required}"
    : "${2:?Error: Second argument (destination) is required}"
    echo "Source: $1"
    echo "Destination: $2"
}

# This call succeeds
echo "With both arguments:"
require_args "input.txt" "output.txt"

# Uncommenting the line below would print an error and exit:
# require_args "only_one"
#
# Output would be:
# script: line N: 1: Error: First argument (source file) is required
Output:
With both arguments:
Source: input.txt
Destination: output.txt

For arguments beyond $9, you must use braces: ${10}, ${11}, etc. Without braces, $10 is interpreted as $1 followed by the literal character 0.

This rarely comes up since most scripts don’t take 10+ positional arguments, but it’s important to know.

Edit
#!/bin/sh
set -- a b c d e f g h i j k l

echo "Without braces (wrong):"
echo "  \$10 = $10  (actually \$1 + '0')"

echo ""
echo "With braces (correct):"
echo "  \${10} = ${10}"
echo "  \${11} = ${11}"
echo "  \${12} = ${12}"
Output:
Without braces (wrong):
  $10 = a0  (actually $1 + '0')

With braces (correct):
  ${10} = j
  ${11} = k
  ${12} = l

Bash

Bash provides additional syntax for working with arguments:

  • ${@:n} - All arguments starting at position n
  • ${@:n:m} - Slice of m arguments starting at position n
  • ${!#} - The last argument (indirect expansion)

These are useful when you need to skip the first few arguments or access the last one directly. Note: These features are Bash-specific and not available in POSIX sh.

Edit
#!/bin/bash
show_slicing() {
    echo "All arguments: $*"
    echo "Last argument (\${!#}): ${!#}"
    echo "Skip first (\${@:2}): ${*:2}"
    echo "Args 2-3 (\${@:2:2}): ${*:2:2}"
}

show_slicing one two three four five
Output:
All arguments: one two three four five
Last argument (${!#}): five
Skip first (${@:2}): two three four five
Args 2-3 (${@:2:2}): two three

This pattern combines everything into a complete argument parser. The while/case loop handles flags and options:

  • -h|--help shows usage and exits
  • -v|--verbose sets a flag variable
  • -- signals end of options (remaining args are files)
  • -* catches unknown options (report error)
  • * breaks the loop to process positional arguments
Edit
#!/bin/sh
handle_args() {
    verbose=""

    while [ $# -gt 0 ]; do
        case "$1" in
            -h | --help)
                echo "Usage: script [-v|--verbose] [--] files..."
                return 0
                ;;
            -v | --verbose)
                verbose=true
                shift
                ;;
            --)
                shift
                break
                ;;
            -*)
                echo "Unknown option: $1" >&2
                return 1
                ;;
            *)
                break
                ;;
        esac
    done

    [ "$verbose" = true ] && echo "Verbose mode enabled"
    echo "Files to process: $*"
}

handle_args -v -- file1.txt file2.txt
Output:
Verbose mode enabled
Files to process: file1.txt file2.txt

« Traps | Getopts »