A wrapper should pass the arguments it received without changing their boundaries. In Bash, "$@" does that when used as command arguments: each positional parameter becomes a separate quoted word. This matters for empty values, spaces, newlines, and literal wildcard characters. A command that happens to work with two simple filenames can still be broken if its wrapper reconstructs the list as a single string.
A list is different from joined text
The positional parameters are the arguments of the current script or function. $# reports their count, including arguments whose value is an empty string. "$1" selects the first argument, while "${10}" selects the tenth; braces distinguish that reference from the first argument followed by a zero. $0 identifies the invoked shell or script and is not an extra element of "$@".
At a normal command boundary, "$@" preserves each parameter as its own word. With no positional parameters, it produces no arguments. Quoted "$*" instead produces one string, joining parameter values with the first character of IFS. If IFS is unset, the join uses spaces; if it is empty, the join inserts no separator. The original spaces inside values remain, but the original boundaries cannot generally be recovered.
Unquoted $@ is not an equivalent shorthand. Its values can undergo word splitting and filename expansion. A literal star supplied by the caller may suddenly match files in the wrapper’s directory. Adding quote characters to a joined string does not restore the lost structure. In Bash, save a list with args=("$@") and forward it with "${args[@]}" when it must survive later changes to the positional parameters.
When it breaks
A function receives its own positional parameters while it runs. Calling it with arguments replaces the visible list for that call, and returning restores the caller’s list. Using set or shift inside the function changes that function’s list. This does not mean every other shell setting is local to the function: changing IFS without a local declaration, for example, can affect the caller. Limit variable changes deliberately.
Shift removes leading positional parameters and renumbers the remainder. Check that enough parameters remain before consuming an option and its value; shifting beyond the available count fails. A parser that only checks for a nonzero count can still mishandle an option requiring a second argument. Preserve the original list in an array if diagnostics or later forwarding require values already consumed by parsing.
Quoting also cannot prevent the receiving command from interpreting an argument as an option. A filename equal to --help remains one intact argument under "$@", but a tool might treat it as a help request. Use the tool’s documented option terminator or a suitable pathname prefix when passing operands. The wrapper must decide where its own options end and where the downstream command’s options begin.
For diagnostics, print the count and each argument separately. Joining everything with "$*" can make zero arguments, an empty argument, and arguments containing separators look misleadingly similar. Bash printf’s %q is useful for showing otherwise invisible boundaries, but logging and forwarding serve different purposes. Forward the saved list itself; do not execute a reconstructed log line.
Check it quickly
Run this complete Bash block. It verifies a four-element list containing an empty value, a spaced pathname, a literal star, and an option-like value. It then checks function scope, the difference between the two quoted expansions with no arguments, and an attempted shift beyond the list length. No files or external services are needed.
bash --noprofile --norc <<'BASH'
set -e
IFS=$' \t\n'
set -- '' 'report final.md' '*.log' '--help'
saved=("$@")
verify() {
[[ $# -eq 4 && $1 == '' && $2 == 'report final.md' ]]
[[ $3 == '*.log' && $4 == '--help' ]]
printf 'arg <%q>\n' "$@"
}
verify "${saved[@]}"
consume() {
[[ $# -eq 2 ]]
shift
[[ $# -eq 1 && $1 == child-two ]]
set -- replaced
[[ $1 == replaced ]]
}
consume child-one child-two
[[ $# -eq 4 && $2 == 'report final.md' ]]
joined=$(IFS=,; printf '%s' "$*")
[[ $joined == ',report final.md,*.log,--help' ]]
count() { printf '%s' "$#"; }
set --
[[ $(count "$@") == 0 ]]
[[ $(count "$*") == 1 ]]
set -- sole
if shift 2; then
printf 'unexpected shift success\n' >&2
exit 1
fi
[[ $# -eq 1 && $1 == sole ]]
printf 'PASS: forwarding, function scope, empty list, shift limit\n'
BASH
Expect four escaped argument lines and the final PASS message. The first argument is explicitly empty, and the star remains literal. Returning from consume must leave the outer four-element list unchanged. A failed oversized shift must preserve the single remaining argument. These assertions test argument transport; a production wrapper still needs tests for its particular option grammar, required values, and downstream invocation.
Sources
- GNU Bash: Special Parameters, positional-list expansions, joining, and counts, checked 2026-09-06.
- GNU Bash: Arrays, saving and forwarding separate elements, checked 2026-09-06.
- GNU Bash: Word Splitting, unquoted expansions and empty arguments, checked 2026-09-06.