Command-Line Arguments ($1, $@, $#)

When you run a script like ./deploy.sh production --force, the words after the script name aren’t magic — Bash hands them to your script as a numbered list of strings called positional parameters. Learning to read $1, $@, $#, and their relatives is what turns a script from a fixed, one-off recipe into a reusable tool that behaves like any other command-line program.

Overview: how arguments reach a script

When you type a command at the shell, Bash splits the line into words (respecting quotes) and uses that list to build a program’s argv array — the same mechanism the kernel uses to launch any executable, not just shell scripts. For a compiled C program, argv[0] is the program name and argv[1], argv[2], and so on are the arguments. Bash exposes this exact same array to your script through special variables:

  • $0 — the name the script was invoked with (e.g. ./deploy.sh)
  • $1, $2, $3, … — the first, second, third argument, and so on. These are called positional parameters.
  • $# — the count of positional parameters (not counting $0)
  • $@ and $* — both expand to \”all the arguments\”, but they behave very differently when quoted (more on this below — it’s the single most important thing to understand on this page)

These variables only exist inside a script (or a function, where they refer to the function’s own arguments) — you can’t reassign $1 directly the way you assign a normal variable, because it’s derived from the process’s argument list. If you need to change it, you use the shift builtin, which discards $1 and renumbers everything else down by one: what was $2 becomes the new $1, what was $3 becomes $2, and $# decreases by one. This is how scripts process an unknown number of arguments one at a time in a loop.

Why $@ and $* are not interchangeable

Both expand to the full list of arguments, but the difference shows up the moment you quote them:

  • "$@" expands to each positional parameter as its own separately-quoted word — exactly as the arguments were originally passed. This is almost always what you want when forwarding arguments to another command or looping over them.
  • "$*" expands to a single word: all the arguments joined together, separated by the first character of the IFS variable (a space, by default).
  • Unquoted, $@ and $* behave identically — both get word-split and glob-expanded like any other unquoted expansion, which is almost never what you want (see Common Mistakes below).

Because of this, the standard advice — and the one this course follows — is: always write "$@", in double quotes, unless you have a specific reason to collapse the arguments into one string.

Syntax

There’s no special \”syntax\” to invoke argument passing — any word after the script name on the command line becomes an argument automatically:

./script_name.sh "<argument1>" "<argument2>" ...

Inside the script, the table below covers every variable you’ll use to read those arguments:

Variable Meaning
$0 The script’s own name/path as invoked
$1$9 The 1st through 9th positional parameters
${10}, ${11}, … 10th and beyond — braces are required past $9 (see Common Mistakes)
$# Number of positional parameters (excludes $0)
"$@" All arguments as separate, individually-quoted words — use this in loops and when forwarding args
"$*" All arguments joined into a single string (separated by the first char of IFS)
shift Discards $1 and shifts every other parameter down by one position
shift n Discards the first n parameters at once
${1:-default} Use $1 if it’s set and non-empty, otherwise use default
${1:?message} Use $1 if set, otherwise print message to stderr and exit

Examples

Example 1: the basics — $0, $1, $#, $@ and $*

Save this as args_demo.sh, run chmod +x args_demo.sh, then call it as ./args_demo.sh apple banana "cherry pie":

#!/usr/bin/env bash

echo "Script name: $0"
echo "First argument: $1"
echo "Second argument: $2"
echo "All arguments (as one word): $*"
echo "All arguments (as separate words): $@"
echo "Number of arguments: $#"

Output:

Script name: ./args_demo.sh
First argument: apple
Second argument: banana
All arguments (as one word): apple banana cherry pie
All arguments (as separate words): apple banana cherry pie
Number of arguments: 3

Notice $# is 3, not 2 — even though "cherry pie" contains a space, it was passed as one quoted argument on the command line, so it counts as a single positional parameter. The difference between $* and $@ isn’t visible yet here because neither is quoted in this echo — it only shows up when you loop over them, which is Example 2.

Example 2: validating argument count and looping with “$@”

Real scripts should check that they got the arguments they need before using them. Save this as deploy.sh:

#!/usr/bin/env bash
set -euo pipefail

if [[ $# -eq 0 ]]; then
    echo "Usage: $0 <file1> [file2 ...]" >&2
    exit 1
fi

echo "Deploying $# file(s):"
for file in "$@"; do
    echo "  - $file"
done

Run it as ./deploy.sh "release notes.txt" config.yaml:

Output:

Deploying 2 file(s):
  - release notes.txt
  - config.yaml

Because the loop uses for file in "$@" (quoted), release notes.txt stays one item despite its space. If you ran ./deploy.sh with no arguments at all, the $# -eq 0 check catches it, prints a usage message to stderr, and exits with status 1 before anything else runs.

Example 3: shift and default values together

Save this as backup.sh — it treats the first argument as an optional destination (defaulting to /backups) and everything after it as the list of things to back up:

#!/usr/bin/env bash
set -euo pipefail

destination="${1:-/backups}"
shift || true

echo "Backing up to: $destination"
echo "Remaining arguments after shift: $#"

for source in "$@"; do
    echo "Would back up: $source"
done

Run it as ./backup.sh /mnt/backups /etc/nginx /etc/ssh:

Output:

Backing up to: /mnt/backups
Remaining arguments after shift: 2
Would back up: /etc/nginx
Would back up: /etc/ssh

The ${1:-/backups} expansion reads $1 without consuming it, so it still works even if no argument was given at all. shift then removes that first argument, so the remaining loop over "$@" only sees the source paths. The || true after shift matters under set -e: if the script is called with zero arguments, shift has nothing to remove and would normally exit with an error, which would kill the script before it could print anything.

How it works step by step

When the shell runs ./deploy.sh production --force, here’s what actually happens:

  • Bash parses the command line into words, respecting any quoting, and resolves ./deploy.sh to an executable file.
  • Bash forks a child process and that child calls execve(), the kernel system call that replaces the process image with the script’s interpreter (here, /usr/bin/env bash, per the shebang line) and passes it an argv array: ["./deploy.sh", "production", "--force"].
  • The new Bash instance that runs your script sees that argv array and exposes it through the positional parameters: $0 is ./deploy.sh, $1 is production, $2 is --force, and $# is 2 (argv’s length minus the program name).
  • Every reference to $1, "$@", etc. inside the script is just a read of this in-memory list — nothing is re-parsed from the original command line each time.
  • If the script calls shift, Bash drops the first element of that internal list and renumbers the rest; it does not touch the original process’s argv, only the shell’s view of it.
  • When the script exits, its exit status (0255) becomes available to whatever called it via $? — a separate mechanism from the arguments, but worth remembering since scripts that validate $# typically exit 1 on failure so calling code (or CI) can detect the problem.

Common Mistakes

Mistake 1: looping over $@ (or $*) unquoted

Unquoted, $@ undergoes word-splitting just like any other unquoted expansion — arguments containing spaces get torn apart into extra loop iterations.

Wrong — run as ./process.sh "monthly report.txt" invoice.pdf:

#!/usr/bin/env bash

for file in $@; do
    echo "Processing: $file"
done

Output (wrong — 3 items instead of 2):

Processing: monthly
Processing: report.txt
Processing: invoice.pdf

Corrected — quote it:

#!/usr/bin/env bash

for file in "$@"; do
    echo "Processing: $file"
done

Output (correct — 2 items):

Processing: monthly report.txt
Processing: invoice.pdf

Mistake 2: reading $1 without checking $# first

If a script assumes an argument exists and it doesn’t, you either silently work with an empty string or, under set -u, crash with a somewhat cryptic error.

Wrong — run with no arguments:

#!/usr/bin/env bash
set -euo pipefail

name="$1"
echo "Hello, $name"

Output (wrong — script aborts):

./greet.sh: line 4: $1: unbound variable

Corrected — validate before using, with a clear usage message:

#!/usr/bin/env bash
set -euo pipefail

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

name="$1"
echo "Hello, $name"

Mistake 3: expecting $10 to mean the tenth argument

Bash only treats $1 through $9 as single-digit positional parameters. $10 is parsed as $1 followed by a literal 0, not the tenth argument.

Wrong — run as ./show.sh a b c d e f g h i j:

#!/usr/bin/env bash

echo "Tenth argument: $10"

Output (wrong — prints $1 plus a literal \”0\”):

Tenth argument: a0

Corrected — brace the number:

#!/usr/bin/env bash

echo "Tenth argument: ${10}"

Output (correct):

Tenth argument: j

Best Practices

  • Default to "$@" (quoted) whenever you need \”all the arguments\” — reach for "$*" only when you deliberately want them joined into one string.
  • Validate $# near the top of the script and print a Usage: message to stderr (>&2) before exiting non-zero, so callers immediately know how to fix their invocation.
  • Use ${1:-default} for optional arguments and ${1:?message} for arguments that are required but might be missing — both are more concise than a separate if check.
  • Prefer ${10}, ${11}, … (with braces) once you’re past nine arguments, or better, use shift in a loop instead of hardcoding many positional numbers.
  • Combine shift with a while [[ $# -gt 0 ]]; do ... done loop to build flexible option parsers that handle flags like --verbose or -o value in any order.
  • Always quote positional parameters when using them ("$1", not $1) so filenames or values containing spaces or glob characters aren’t split or expanded unexpectedly.

Practice Exercises

  • Exercise 1: Write greet.sh that requires a name as $1 and accepts an optional greeting word as $2 (defaulting to Hello if omitted). It should print Hello, Ana! for ./greet.sh Ana and Hi, Ana! for ./greet.sh Ana Hi. Hint: use ${2:-Hello}.
  • Exercise 2: Write a script that accepts any number of filenames, prints an error and exits with status 1 if none are given, and otherwise prints You passed N file(s): followed by each filename on its own line using a quoted "$@" loop.
  • Exercise 3: Write a script that treats its first argument as a required action (start or stop) and every argument after that as options to display. Use shift to remove the action, then loop over the remaining "$@" to print each option. Test it with ./service.sh start --verbose --port 8080 and confirm $# is 2 after the shift.

Summary

  • $0 is the script’s own name; $1, $2, … are the positional parameters (arguments) it was called with.
  • $# gives the argument count; past $9 you must use braces, as in ${10}.
  • "$@" expands to each argument as a separate quoted word — use this, quoted, almost everywhere. "$*" joins everything into a single string instead.
  • Unquoted $@/$* both word-split, which breaks on arguments containing spaces — this is a very common bug.
  • shift discards $1 and renumbers the rest, letting you process an arbitrary number of arguments in a loop.
  • ${1:-default} and ${1:?message} give clean, one-line handling of optional and required arguments.
  • Always check $# before relying on an argument being present, and print a Usage: message to stderr when it isn’t.