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 theIFSvariable (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.shto 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:
$0is./deploy.sh,$1isproduction,$2is--force, and$#is2(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 (
0–255) becomes available to whatever called it via$?— a separate mechanism from the arguments, but worth remembering since scripts that validate$#typicallyexit 1on 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 aUsage: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 separateifcheck. - Prefer
${10},${11}, … (with braces) once you’re past nine arguments, or better, useshiftin a loop instead of hardcoding many positional numbers. - Combine
shiftwith awhile [[ $# -gt 0 ]]; do ... doneloop to build flexible option parsers that handle flags like--verboseor-o valuein 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.shthat requires a name as$1and accepts an optional greeting word as$2(defaulting toHelloif omitted). It should printHello, Ana!for./greet.sh AnaandHi, 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
1if none are given, and otherwise printsYou 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 (
startorstop) and every argument after that as options to display. Useshiftto remove the action, then loop over the remaining"$@"to print each option. Test it with./service.sh start --verbose --port 8080and confirm$#is2after the shift.
Summary
$0is the script’s own name;$1,$2, … are the positional parameters (arguments) it was called with.$#gives the argument count; past$9you 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. shiftdiscards$1and 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 aUsage:message to stderr when it isn’t.
