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.
$@ 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:
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
-
Set a
PROGNAME. Capturebasename "$0"near the top so usage messages always carry the right name even if the script is renamed:PROGNAME=$(basename "$0"). -
Validate argument count early. Right after
PROGNAME, check$#and print a usage hint if the user gave too few:[[ $# -lt 1 ]] && { usage; exit 1; }. -
Choose your parser. For 1–3 simple short flags,
getopts. For long options or non-trivial validation, awhile/shiftloop dispatched viacase. -
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. -
Forward with
"$@". When wrapping another command (my-wrapper foo bar baz→actual-tool foo bar baz), end withexec 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.
Related lessons
Related concepts
- Command Line Argumentslinked concept
- Control Flowlinked concept