Skip to content

Positional Parameters


Every script and function you’ve written so far in this course has taken zero arguments. That changes here — this chapter covers how Bash exposes whatever gets passed on the command line, plus a handful of special variables ($?, already familiar if you following the course from the beginning, alongside $$ and $!) that report on the running shell itself rather than on anything you typed.

Positional Parameters: $1, $2, … And Beyond

Arguments passed to a script show up as positional parameters, numbered from 1:

#!/usr/bin/env bash

echo "First argument: $1"
echo "Second argument: $2"
./greet.sh Alice Smith
First argument: Alice
Second argument: Smith

Beyond $9, braces become required — $10 is actually parsed as ${1}0 (positional parameter 1, followed by a literal 0), not positional parameter ten:

echo "${10}"

Scripts that expect that many arguments are rare, but worth knowing the rule for regardless.

$0 — The Script’s Own Name

$0 holds the name the script was invoked as — not necessarily just its filename:

#!/usr/bin/env bash

echo "I was invoked as: $0"
./whoami.sh
I was invoked as: ./whoami.sh
/home/you/shell-course/ch1/whoami.sh
I was invoked as: /home/you/shell-course/ch1/whoami.sh

Run the exact same script two different ways and $0 reflects however it was actually called — relative path, absolute path, or bare name if found via PATH. There’s a sharper wrinkle related to execution-vs-sourcing distinction: when a script is sourced rather than executed, $0 does not change to the sourced script’s name — it continues reporting whatever $0 already was in the shell that did the sourcing (typically bash, or the name of an interactive shell). This is one more concrete consequence of sourcing running code inside the current shell rather than a new process: there’s no new script identity for $0 to report, because no new process was ever created.

$# — Argument Count

#!/usr/bin/env bash

echo "You passed $# argument(s)."
./count.sh a b c
You passed 3 argument(s).

$# is the standard way to validate that a script received the arguments it expects, before trying to use them:

if [[ "$#" -lt 2 ]]; then
    echo "Usage: $0 <source> <destination>" >&2
    exit 1
fi

$@ vs. $* — Two Ways To Reference All Arguments

This is exactly the "${array[@]}" vs. "${array[*]}" distinction from the previous chapter, applied to positional parameters instead of an array — and it behaves identically:

show-args.sh
#!/usr/bin/env bash

echo "--- Using \$@ ---"
for arg in "$@"; do
    echo "[$arg]"
done

echo "--- Using \$* ---"
for arg in "$*"; do
    echo "[$arg]"
done
./show-args.sh "hello world" foo
--- Using $@ ---
[hello world]
[foo]
--- Using $* ---
[hello world foo]

Quoted, "$@" expands to each positional parameter as its own separate, fully preserved word — two arguments in, two loop iterations out. Quoted, "$*" joins everything into a single string instead — both arguments flattened together, losing the boundary between them entirely.

Forwarding Arguments With "$@"

The most common real use for this distinction is a wrapper script that needs to pass its own arguments straight through to another command, unchanged:

#!/usr/bin/env bash

./actual-tool.sh "$@"

"$@" is the only one of the two forms that reliably preserves the original argument boundaries when forwarding — this is worth internalizing as a fixed pattern, since getting it wrong is a common, easy-to-miss bug covered in full in this chapter’s Shell-Safety Considerations section.

$? — Exit Status

You’ve used this since Chapter 1: $? holds the exit status of the most recently completed command. Positional-parameter-wise, it’s worth noting explicitly that $? is not a positional parameter itself — it’s one of several special parameters Bash maintains automatically, alongside the others in this chapter.

$$ — Process ID

$$ holds the process ID of the current shell (or script):

echo "This script's PID is $$"

A historically common use is building a unique-per-run temporary filename:

temp_file="/tmp/myscript.$$.tmp"
echo "Using temp file: $temp_file"

Note

This pattern is simple, but has a real weakness: process IDs get reused by the operating system once a process exits, so a leftover temp file from a crashed earlier run with the same PID could still be sitting there. A more robust tool for generating genuinely unique temporary files, mktemp, is covered later in the course — keep $$ in mind as an easy, if imperfect, option until then.

$! — Last Background Process ID

$! holds the process ID of the most recently started background job — a command launched with a trailing &:

sleep 5 &
echo "Started background process with PID $!"
Started background process with PID 48213

Managing background jobs — checking on them, waiting for them to finish, collecting their exit status — is covered as its own topic later in the course; for now, just recognize $! as the variable that captures which process you just backgrounded.

shift — Consuming Arguments One At A Time

shift discards $1 and moves every remaining positional parameter down by one — $2 becomes the new $1, $3 becomes the new $2, and $# decreases by one to match:

#!/usr/bin/env bash

while [[ "$#" -gt 0 ]]; do
    echo "Next argument: $1"
    shift
done
./process-all.sh alpha beta gamma
Next argument: alpha
Next argument: beta
Next argument: gamma

This loop-and-shift pattern is the standard way to process an unknown, variable-length list of arguments one at a time, without needing to know in advance how many there are. Also, shift 2 shifts by more than one position at once, if you need to discard more than just $1 in a single step.

Best Practices

  • Validate $# before relying on specific positional parameters — a script that assumes $1 and $2 exist without checking $# first fails confusingly (usually with an empty value rather than a clear error) when called with too few arguments.
  • Default to "$@", quoted, for forwarding arguments to another command — treat unquoted $* or $@ as something you’d only use deliberately, in the rare case you actually want everything joined into one string.
  • Print usage information to stderr (>&2), not stdout, when a script exits early due to missing or invalid arguments — this follows directly from the stdout/stderr separation covered in Chapter 5.
  • Reach for the while [[ $# -gt 0 ]]; do ... shift; done pattern whenever a script needs to handle a variable number of arguments, rather than hardcoding a fixed set of $1, $2, $3 checks.

Shell-Safety Considerations

The "$@" vs. $* distinction isn’t just a subtlety worth knowing — getting it backwards in a wrapper script silently corrupts exactly the arguments it was supposed to forward untouched.

Naive (broken) version — forwarding with unquoted $*:

cat > inner.sh << 'EOF'
#!/usr/bin/env bash
i=1
for arg in "$@"; do
    echo "Arg $i: [$arg]"
    i=$((i + 1))
done
EOF
chmod +x inner.sh

cat > wrapper-broken.sh << 'EOF'
#!/usr/bin/env bash
./inner.sh $*
EOF
chmod +x wrapper-broken.sh

./wrapper-broken.sh "hello world" foo
Arg 1: [hello]
Arg 2: [world]
Arg 3: [foo]

Two arguments went in — "hello world" and foo — but three came out the other side. $*, unquoted, joined the wrapper’s own arguments into one space-separated string and then let that string get word-split all over again on its way into inner.sh, destroying the original boundary between "hello world" and foo entirely.

Corrected version — forwarding with "$@":

cat > wrapper-fixed.sh << 'EOF'
#!/usr/bin/env bash
./inner.sh "$@"
EOF
chmod +x wrapper-fixed.sh

./wrapper-fixed.sh "hello world" foo
Arg 1: [hello world]
Arg 2: [foo]

Exactly two arguments, exactly as passed in. "$@" is the only form of the two that survives being forwarded through an intermediate script without its argument boundaries getting flattened and re-split along the way.

Hands-On: Arguments, Process IDs, And Safe Forwarding

1. Set up a working directory.

mkdir -p ~/shell-course/ch10
cd ~/shell-course/ch10

2. Write a script that reports its own basic argument info.

cat > info.sh << 'EOF'
#!/usr/bin/env bash
echo "Invoked as: $0"
echo "Argument count: $#"
echo "First argument: $1"
echo "This script's PID: $$"
EOF
chmod +x info.sh

./info.sh alpha beta

3. Confirm $0 behaves differently when sourced.

source info.sh alpha beta

Compare this output’s first line against step 2’s — sourced, $0 reports your current shell rather than info.sh, exactly as described earlier in this chapter.

4. Process a variable number of arguments with shift.

cat > process-all.sh << 'EOF'
#!/usr/bin/env bash
while [[ "$#" -gt 0 ]]; do
    echo "Next argument: $1"
    shift
done
EOF
chmod +x process-all.sh

./process-all.sh alpha beta gamma

5. Reproduce the $* argument-forwarding bug, then fix it.

cat > inner.sh << 'EOF'
#!/usr/bin/env bash
i=1
for arg in "$@"; do
    echo "Arg $i: [$arg]"
    i=$((i + 1))
done
EOF
chmod +x inner.sh

cat > wrapper-broken.sh << 'EOF'
#!/usr/bin/env bash
./inner.sh $*
EOF
chmod +x wrapper-broken.sh

cat > wrapper-fixed.sh << 'EOF'
#!/usr/bin/env bash
./inner.sh "$@"
EOF
chmod +x wrapper-fixed.sh

echo "--- broken ---"
./wrapper-broken.sh "hello world" foo

echo "--- fixed ---"
./wrapper-fixed.sh "hello world" foo

6. Clean up.

cd ~
rm -rf ~/shell-course/ch10

You’ve now read positional parameters, checked $# before relying on them, watched $0 change based on execution versus sourcing, used shift to process an open-ended argument list, and confirmed directly why "$@" — not $* — is the correct way to forward arguments through a wrapper script untouched. The next chapter covers quoting rules in full depth — the single biggest source of real-world Bash bugs, building on every word-splitting example you’ve already seen throughout this course.

Last updated on