Positional Parameters

3 min read

Core idea

When you launch a script, bash splits the command line into words and makes them available through a small family of pre-named variables: $0 is the script itself, $1 through $9 (and ${10}+ with braces) are the user-supplied arguments, $# is the count, and $@ / $* expand to all of them. shift rotates them down, and getopts parses single-letter options. Together these are how a script reaches the outside world — every flag, filename, and switch a user types ends up here.

Why it matters

A program that can't accept arguments isn't really a program — it's a fixed recipe. Positional parameters turn a shell script from a one-off snippet into a reusable command. They're also where most beginner scripts go wrong: forgetting to quote "$@", treating "$1" as if it were always present, or hand-rolling option parsing when getopts would do. Mastering this small vocabulary is the leap from "I wrote a script that works on my machine" to "I wrote a script that survives unexpected input."

Mental model

The parameter family at a glance

There are about eight identifiers in this family. They look cryptic until you see them grouped by purpose: identity ($0), arguments ($1…$N, ${N}), collections ($@, $*, $#), and status ($?, $$, $!). Everything else builds on these.

The parameter family at a glance

$@ vs $* — the most-bitten-by gotcha

$@ and $* look identical until you put them in double quotes and pass arguments containing spaces:

| Form | With args word and words with spaces | | ------- | -------------------------------------------------------- | | $@ | 4 words: word, words, with, spaces | | $* | 4 words: same as above | | "$*" | 1 word: "word words with spaces" | | "$@" | 2 words: "word", "words with spaces" |

Only "$@" does what you mean. Internalise the rule: always quote "$@", never quote "$*" unless you specifically want one joined string.

shift and the argument-processing loop

shift discards $1 and renumbers the rest. It is the engine of every hand-rolled argument loop:

shift and the argument-processing loop

getopts — when to reach for it

getopts is bash's builtin short-option parser. It handles bundled flags (-vh), arguments-with-values (-f file.txt), and reports unknown options or missing values. Trade-offs:

| Need | Use | | ----------------------------------- | -------------- | | Short options only (-v, -f X) | getopts | | Bundled short options (-vxf X) | getopts | | Long options (--verbose, --file)| while/shift| | Both short and long | external getopt or hand-rolled | | Full POSIX compliance & portability | while/shift|

Practical application

  1. Set a PROGNAME. Capture basename "$0" near the top so usage messages always carry the right name even if the script is renamed: PROGNAME=$(basename "$0").

  2. Validate argument count early. Right after PROGNAME, check $# and print a usage hint if the user gave too few: [[ $# -lt 1 ]] && { usage; exit 1; }.

  3. Choose your parser. For 1–3 simple short flags, getopts. For long options or non-trivial validation, a while/shift loop dispatched via case.

  4. Quote relentlessly. Every reference to $1, $2, or $@ belongs in double quotes — "$1", "$@". Unquoted positional parameters word-split on spaces and glob-expand against the cwd.

  5. Forward with "$@". When wrapping another command (my-wrapper foo bar bazactual-tool foo bar baz), end with exec actual-tool "$@". This is the only form that survives spaces in arguments.

Example

Suppose we want a slurp wrapper that downloads a URL with curl, optionally retrying and optionally writing to a file. The parser needs -r N for retries, -o FILE for output, and any remaining positional arguments are URLs to fetch:

#!/usr/bin/env bash
PROGNAME=$(basename "$0")
retries=0
output=""

usage() {
  cat <<EOF >&2
Usage: $PROGNAME [-r RETRIES] [-o FILE] URL [URL ...]
EOF
}

while getopts ":r:o:h" opt; do
  case "$opt" in
    r)  retries="$OPTARG" ;;
    o)  output="$OPTARG" ;;
    h)  usage; exit 0 ;;
    \?) echo "$PROGNAME: unknown flag -$OPTARG" >&2; usage; exit 1 ;;
    :)  echo "$PROGNAME: -$OPTARG needs a value" >&2; usage; exit 1 ;;
  esac
done
shift $((OPTIND - 1))

[[ $# -lt 1 ]] && { usage; exit 1; }

for url in "$@"; do
  curl --retry "$retries" ${output:+--output "$output"} "$url"
done

The key moves: OPTIND slides forward as getopts consumes flags, shift $((OPTIND - 1)) drops the consumed flags so "$@" is now only URLs, and the ${output:+--output "$output"} expansion adds the --output flag only when $output is non-empty.

Continue exploring

Tags