opscape

Bash scripting guide · part 26 of 32

Script arguments

Positional parameters $1 to ${10}, $# and $0, why "$@" is almost always right and "$*" rarely, shift, getopts, and ending options with --.

The words after a script's name on the command line become its positional parameters: $1, $2 and so on. $# holds how many there are, and $0 is the name the script was called by. Functions get their own set while they run, which is why the same rules apply to function arguments (see Functions).

The basics

Read the first arguments and count them

args.sh
#!/bin/bash
echo "script: $0"
echo "count:  $#"
echo "first:  $1"
echo "second: $2"
echo "tenth:  ${10}"
bash args.sh alpha "beta gamma" 3 4 5 6 7 8 9 ten
explain
output
script: args.sh
count:  10
first:  alpha
second: beta gamma
tenth:  ten

"beta gamma" was quoted on the command line, so it arrived as one argument, $2. Past nine you need braces: $10 would be $1 followed by a literal 0, while ${10} is the tenth argument. $0 is the path as typed, here args.sh.

"$@" versus "$*"

Both expand to all the arguments, and the difference only shows when they are quoted:

  • "$@" expands to each argument as a separate word, exactly as received. Spaces inside an argument are preserved, and no argument is split or merged.
  • "$*" joins all arguments into one word, separated by the first character of IFS (a space by default).
  • Unquoted, $@ and $* are the same and both are subject to word splitting and globbing, which destroys arguments containing spaces.

The three ways of passing arguments on

show.sh
#!/bin/bash
printf '  [%s]\n' "$@"
wrapper.sh
#!/bin/bash
echo '"$@":';  bash show.sh "$@"
echo '"$*":';  bash show.sh "$*"
echo 'bare $@:'; bash show.sh $@
bash wrapper.sh "file with spaces.txt" notes.txt
explain
output
"$@":
  [file with spaces.txt]
  [notes.txt]
"$*":
  [file with spaces.txt notes.txt]
bare $@:
  [file]
  [with]
  [spaces.txt]
  [notes.txt]

show.sh prints each argument it receives in brackets. Only "$@" passed the two arguments through intact. "$*" merged them into one, and unquoted $@ split the first one into three words. When a script forwards its arguments to another command, "$@" is the right choice in practically every case.

Where "$*" is useful

set -- web01 web02 db01
echo "hosts: $*"
IFS=,
echo "csv: $*"
output
hosts: web01 web02 db01
csv: web01,web02,db01

set -- replaces the current shell's positional parameters, a convenient way to try these out. "$*" is the tool for joining arguments into one string, for a message or a log line. With IFS set to a comma, the join uses commas.

Requiring and defaulting arguments

Parameter expansion handles the common checks in one line. ${1:-default} substitutes a default when $1 is missing or empty; ${1:?message} aborts the script with the message. For more than one argument, check $# instead.

Fail early when an argument is missing

deploy.sh
#!/bin/bash
target=${1:?usage: deploy.sh TARGET [ENV]}
env=${2:-staging}
echo "deploying $target to $env"
bash deploy.sh web01
bash deploy.sh web01 production
bash deploy.sh
outputexit 1
deploying web01 to staging
deploying web01 to production
deploy.sh: line 2: 1: usage: deploy.sh TARGET [ENV]

The first two calls used the default staging and an explicit value. The third had no target, so ${1:?...} printed the script name, the line number, the parameter's name (1) and the message to standard error, and exited with status 1.

Looping over arguments and shift

for arg; do (with no in list) loops over "$@". shift drops $1 and moves every other argument down by one; shift 2 drops two. It is the tool for consuming arguments one at a time, typically in a while loop.

Consume arguments with shift

mkuser.sh
#!/bin/bash
group=$1
shift
echo "group: $group ($# users)"
for user; do
  echo "  adding $user"
done
bash mkuser.sh admins alice bob carol
explain
output
group: admins (3 users)
  adding alice
  adding bob
  adding carol

After shift, the group name is gone from the list: $# dropped from 4 to 3, and the for loop saw only the users.

Options with getopts

getopts is the shell's built-in option parser. It handles single-letter options, clustering (-vo file), options with arguments (attached -ofile or separate -o file), and the -- end marker. You give it an option string: each letter is an option, a letter followed by : takes an argument, and a leading : switches on silent error handling, in which your case handles bad options itself.

Each call puts the next option letter in your variable and its argument in OPTARG, and advances OPTIND. After the loop, shift $((OPTIND - 1)) removes the options so that $@ holds only the remaining operands.

A complete getopts loop

report.sh
#!/bin/bash
verbose=0 out=-
while getopts ":vo:h" opt; do
  case $opt in
    v) verbose=1 ;;
    o) out=$OPTARG ;;
    h) echo "usage: report.sh [-v] [-o FILE] INPUT..."; exit 0 ;;
    :) echo "option -$OPTARG needs an argument" >&2; exit 2 ;;
    ?) echo "unknown option -$OPTARG" >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))
echo "verbose=$verbose out=$out inputs: $*"
bash report.sh -v -o summary.txt access.log logs/app.log
bash report.sh -vo summary.txt access.log
bash report.sh -ofile.txt access.log
output
verbose=1 out=summary.txt inputs: access.log logs/app.log
verbose=1 out=summary.txt inputs: access.log
verbose=0 out=file.txt inputs: access.log

All three spellings parse the same way: separate options, clustered -vo with the argument in the next word, and the argument attached to the letter. After the shift, only the input files remain in $@.

Bad options and the -- marker

report.sh
#!/bin/bash
verbose=0 out=-
while getopts ":vo:" opt; do
  case $opt in
    v) verbose=1 ;;
    o) out=$OPTARG ;;
    :) echo "option -$OPTARG needs an argument" >&2; exit 2 ;;
    ?) echo "unknown option -$OPTARG" >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))
echo "verbose=$verbose out=$out inputs: $*"
bash report.sh -x; echo "status $?"
bash report.sh -o; echo "status $?"
bash report.sh -v -- -o.log
bash report.sh access.log -v
output
unknown option -x
status 2
option -o needs an argument
status 2
verbose=1 out=- inputs: -o.log
verbose=0 out=- inputs: access.log -v

In silent mode an unknown option arrives as ? with the letter in OPTARG, and a missing argument as :. Everything after -- is an operand, so a file literally named -o.log gets through. The last call shows that getopts stops at the first non-option: -v after access.log is treated as an operand, unlike GNU tools, which accept options anywhere.

getopts does not do long options (--verbose). For those, scripts usually parse by hand with while and case, which also makes the -- handling explicit:

Parse long options by hand

sync.sh
#!/bin/bash
dry_run=0 dest=
while (($#)); do
  case $1 in
    -n|--dry-run) dry_run=1 ;;
    -d|--dest)    dest=${2:?--dest needs a value}; shift ;;
    --dest=*)     dest=${1#*=} ;;
    --)           shift; break ;;
    -*)           echo "unknown option: $1" >&2; exit 2 ;;
    *)            break ;;
  esac
  shift
done
echo "dry_run=$dry_run dest=$dest files: $*"
bash sync.sh --dry-run --dest /backup a.txt b.txt
bash sync.sh --dest=/srv -- --weird-name.txt
bash sync.sh --force
outputexit 2
dry_run=1 dest=/backup files: a.txt b.txt
dry_run=0 dest=/srv files: --weird-name.txt
unknown option: --force

Each pass through the loop looks at $1 and shifts it away; an option that takes a value shifts once more. --dest=* splits the attached form with ${1#*=}. The loop stops at -- or at the first operand, and an unknown option exits with status 2, the conventional code for usage errors.

The -- convention

Most commands treat -- as "end of options": every word after it is an operand even if it starts with a dash. Pass -- whenever you hand a variable to a command and cannot be sure it does not start with -, as in rm -- "$file" or grep -- "$pattern" file. Scripts that use getopts get this behavior for free.

Related