$@ vs $*, getopts and its surprises, long options with a loop or GNU getopt, subcommands, validation, defaults and precedence, all tested
▶Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
Bash arguments and options: positional parameters, getopts, long options and getopt
getopts ":vo:n:h" opt; shift $((OPTIND-1))
A script that other people (or cron, or you in six months) will run needs a clean command line: options such as -v and --output FILE, arguments such as file names, a --help that explains itself, and sensible errors when someone gets it wrong. Bash gives you three tools for this: the positional parameters ($1, $@, shift), the built-in getopts for short options, and either a hand-written while/case loop or the external GNU getopt for long options. Each has traps.
This page goes beyond the Bash Programming Tutorial (Lessons 4 and 12 introduce arguments and getopts). It collects the behaviour you only learn by getting it wrong, and every example here was run for real with GNU bash 5.2.21 and util-linux getopt 2.39.3 on Ubuntu 24.04. Surprises are shown as they happened.
Positional parameters: $1, $@, $*, $#, shift
The words after the script name are $1, $2, $3...; $0 is the script's own name; $# is how many arguments there are. The two ways to refer to "all of them" behave very differently, and the difference is the most important thing on this page:
$ cat p1.sh
#!/bin/bash
show() { printf ' [%s]' "$@"; echo; }
echo 'args: "$@"'; show "$@"
echo 'args: $* unquoted'; show $*
echo 'args: "$*"'; show "$*"
echo "count \$#=$# first=[$1] second=[$2]"
$ bash p1.sh "a b" c "d e"
args: "$@"
[a b] [c] [d e]
args: $* unquoted
[a] [b] [c] [d] [e]
args: "$*"
[a b c d e]
count $#=3 first=[a b] second=[c]
"$@" keeps every argument exactly as it was given (three words: "a b", "c" and "d e" with its two spaces). Unquoted $* (and unquoted $@) split on spaces again: five words, the original grouping and spacing are lost. "$*" glues everything into ONE word (joined by the first character of IFS). Rule: always write "$@" when you pass arguments on to another command, and use "$*" only when you really want a single string, for example in a log message.
shift removes $1 and moves the rest down; shift N removes N. It is the basis of every argument loop. A shift past the end fails with status 1, silently (bash only complains if you set shopt -s shift_verbose):
$ cat p2.sh
#!/bin/bash
set -u
echo "name: ${1:-default-name}"
echo "shift demo before: $*"
shift
echo "after shift: $* (\$#=$#)"
shift 5 2>&1 || echo "shift too far failed with status $?"
$ bash p2.sh one two three
name: one
shift demo before: one two three
after shift: two three ($#=2)
shift too far failed with status 1
$ bash p2.sh
name: default-name
shift demo before:
after shift: ($#=0)
shift too far failed with status 1
$ bash -c 'shopt -s shift_verbose; set -- a; shift 3'
bash: line 1: shift: 3: shift count out of range
Other things you can do with the list: ${@:2} is everything from the second argument on, ${@: -1} is the last one (note the space before the minus), set -- a b c replaces all of the arguments, and arrays are the safe way to build an argument list for another command, because they keep each word whole:
$ cat q1.sh (shortened to the lines that matter here)
#!/bin/bash
echo "all: $*"
echo "from 2nd: ${@:2}"
echo "last: ${@: -1}"
set -- x "y z" w
echo "after set --: $# args: $1 | $2 | $3"
wrap() { printf '[%s]' "$@"; echo; }
args=(--flag "value with space")
[[ ${DEBUG:-} ]] && args+=(--debug)
wrap "${args[@]}"
$ bash q1.sh a b c
all: a b c
from 2nd: b c
last: c
after set --: 3 args: x | y z | w
[--flag][value with space]
$ DEBUG=1 bash q1.sh a
...
[--flag][value with space][--debug]
(output trimmed to the lines that differ.) The array "${args[@]}" passes "value with space" as one argument, which a string like "--flag value with space" never could. $0 may contain a path (./tools/run.sh); use ${0##*/} for the bare name in messages.
getopts: the built-in for short options
getopts parses options like -v, -n 5 and -vo file (bundled) in a while loop. Its argument is the option string: each letter is an option, a colon after a letter means "takes a value". A colon at the START of the string turns on "silent mode", where YOU produce the error messages (recommended, since the built-in messages cannot be controlled). The value of an option is in $OPTARG, and OPTIND is the index of the next argument to process. After the loop, shift $((OPTIND-1)) removes the options and leaves the plain arguments:
$ cat g1.sh
#!/bin/bash
verbose=0; out=""; n=1
while getopts ":vo:n:h" opt; do
case $opt in
v) verbose=1 ;;
o) out=$OPTARG ;;
n) n=$OPTARG ;;
h) echo "usage: g1.sh [-v] [-o FILE] [-n NUM] ARG..."; exit 0 ;;
:) echo "g1.sh: option -$OPTARG needs an argument" >&2; exit 2 ;;
\?) echo "g1.sh: unknown option -$OPTARG" >&2; exit 2 ;;
esac
done
shift $((OPTIND-1))
echo "verbose=$verbose out=[$out] n=$n args=($#): $*"
$ bash g1.sh -v -o out.txt a b
verbose=1 out=[out.txt] n=1 args=(2): a b
$ bash g1.sh -vo out.txt -n3 a
verbose=1 out=[out.txt] n=3 args=(1): a
$ bash g1.sh -o
g1.sh: option -o needs an argument
$ bash g1.sh -x
g1.sh: unknown option -x
$ bash g1.sh -h
usage: g1.sh [-v] [-o FILE] [-n NUM] ARG...
Note what works without extra code: bundling (-vo out.txt means -v and -o out.txt), a value attached to the option (-n3) or separate (-n 3), and the two error cases in silent mode. In silent mode the "missing value" case arrives as ":" with OPTARG set to the option letter, and an unknown option as "?" (written \? in the case) with OPTARG set to the letter. The case line \?) needs the backslash, or the shell treats ? as a wildcard that matches everything.
What getopts does not do (and surprises)
All of these were run:
$ bash g1.sh a -v b
verbose=0 out=[] n=1 args=(3): a -v b
$ bash g1.sh -- -v a
verbose=0 out=[] n=1 args=(2): -v a
$ bash g1.sh -o -v a
verbose=0 out=[-v] n=1 args=(1): a
$ bash g1.sh --verbose a
g1.sh: unknown option --
1. Options after the first plain argument are not parsed: "a -v b" left -v as an ordinary argument. getopts stops at the first word that does not start with a dash (like POSIX tools). Many people expect GNU behaviour (options anywhere), as in "ls dir -l". If you need that, use GNU getopt (below) or loop over the leftovers yourself.
2. -- ends the options, so "-- -v a" treats -v as a file name. That is the standard way to pass a file whose name starts with a dash; document it, and put -- in your own commands: rm -- "$file".
3. An option that takes a value takes WHATEVER comes next, even something that looks like an option: "-o -v" stored "-v" as the output file name and never saw a -v option. If that matters, check the value: [[ $OPTARG == -* ]] && error.
4. Long options are not supported at all. "--verbose" is read as the option "-" (with "-v..." following), and in silent mode you get an unknown option whose letter is "-". The message above said "unknown option --" because OPTARG was "-" and the script printed "-$OPTARG". Use one of the next two methods for long options.
5. getopts does not check the VALUES: -n abc is accepted. Validate numbers, paths and choices yourself (see Validating values).
6. It parses only the arguments it is given: inside a function it works on the function's own arguments, which leads to the next trap.
getopts inside a function: local OPTIND
getopts keeps its position in the global variable OPTIND. A function that calls getopts (say, a reusable parse function called twice, or called from a loop) will find OPTIND left over from the first call, and the second call silently parses nothing:
$ cat f2.sh
#!/bin/bash
parse() { local opt; while getopts ":a:" opt; do echo " parse got -$opt $OPTARG"; done; echo " OPTIND is now $OPTIND"; }
echo "first call:"; parse -a 1
echo "second call:"; parse -a 2
$ bash f2.sh
first call:
parse got -a 1
OPTIND is now 3
second call:
OPTIND is now 3
The second call printed no "got" line at all: OPTIND was still 3 and there was nothing at that position. There was no error. The fix is to make OPTIND local to the function (local OPTIND opt), which starts every call at 1 and leaves the caller's value alone. The same applies when you want to parse the same arguments twice in the main script: reset with OPTIND=1.
Long options with a while/case loop
Without any helper, a loop that looks at $1 and shifts is enough for long options. It handles -v, --verbose, --output FILE and --output=FILE, stops at --, rejects unknown options, and collects the plain arguments in an array:
$ cat l1.sh
#!/bin/bash
set -euo pipefail
usage() { cat <<'USAGE'
Usage: l1.sh [options] [--] FILE...
-v, --verbose more output
-o, --output FILE write result to FILE
-n, --count N repeat N times (default 1)
-h, --help show this help
USAGE
}
die() { local rc=$1; shift; echo "l1.sh: $*" >&2; exit "$rc"; }
verbose=0; out=""; count=1; files=()
while (( $# )); do
case $1 in
-v|--verbose) verbose=1 ;;
-o|--output) [[ $# -ge 2 ]] || die 2 "$1 needs a value"; out=$2; shift ;;
--output=*) out=${1#*=} ;;
-n|--count) [[ $# -ge 2 ]] || die 2 "$1 needs a value"; count=$2; shift ;;
--count=*) count=${1#*=} ;;
-h|--help) usage; exit 0 ;;
--) shift; files+=("$@"); break ;;
-?*) die 2 "unknown option: $1 (try --help)" ;;
*) files+=("$1") ;;
esac
shift
done
[[ $count =~ ^[0-9]+$ ]] || die 2 "--count must be a number, got '$count'"
(( ${#files[@]} )) || die 2 "no FILE given (try --help)"
echo "verbose=$verbose out=[$out] count=$count files=(${#files[@]}): ${files[*]}"
$ bash l1.sh -v --output=o.txt --count 3 a "b c"
verbose=1 out=[o.txt] count=3 files=(2): a b c
$ bash l1.sh --help
Usage: l1.sh [options] [--] FILE...
-v, --verbose more output
-o, --output FILE write result to FILE
-n, --count N repeat N times (default 1)
-h, --help show this help
$ bash l1.sh --nope
l1.sh: unknown option: --nope (try --help)
$ bash l1.sh -n abc x
l1.sh: --count must be a number, got 'abc'
$ bash l1.sh
l1.sh: no FILE given (try --help)
$ bash l1.sh -- -weird-name.txt
verbose=0 out=[] count=1 files=(1): -weird-name.txt
$ bash l1.sh -o
l1.sh: -o needs a value
Every error exited with status 2, and options and plain files can be mixed in any order ("x -v y" works), which getopts cannot do. Two limits of this simple loop, shown by a real failed run:
$ bash l1.sh -vn 2 x
l1.sh: unknown option: -vn (try --help)
It does not understand bundled short options (-vn 2): the loop sees one word, "-vn". Either do not promise bundling, or split the bundle first, or switch to GNU getopt, which does it for you. Also note the existence check before "shift" for options with a value: without it, "-o" as the last argument would run "shift" with nothing left and set -u would report a confusing error.
GNU getopt: short and long options, bundling and abbreviations
The external command getopt (from util-linux; the "enhanced" version, not the old BSD one) normalises the command line for you: it splits bundles, separates long options from their values, moves all options before the plain arguments, and puts -- in front of the rest. Your script then only has a simple loop. First check that you have the enhanced version (getopt -T returns 4):
$ getopt -T; echo "getopt -T exit $?"
getopt -T exit 4
$ getopt --version
getopt from util-linux 2.39.3
(On macOS and old BSD systems the system getopt is the old one and this does not work; install util-linux/gnu-getopt there, or use the loop above.)
The pattern: -o gives the short options (a colon means a value), -l the long ones (comma separated), -n the program name for error messages, then -- and "$@". The eval set -- turns the cleaned line back into positional parameters:
$ cat o1.sh
#!/bin/bash
set -euo pipefail
die() { local rc=$1; shift; echo "o1.sh: $*" >&2; exit "$rc"; }
if ! parsed=$(getopt -o vhn:o: -l verbose,help,count:,output:,dry-run -n o1.sh -- "$@"); then
exit 2
fi
eval set -- "$parsed"
verbose=0; dry=0; count=1; out=""
while true; do
case $1 in
-v|--verbose) verbose=1; shift ;;
--dry-run) dry=1; shift ;;
-n|--count) count=$2; shift 2 ;;
-o|--output) out=$2; shift 2 ;;
-h|--help) echo "usage: o1.sh [-v] [-n N] [-o FILE] [--dry-run] FILE..."; exit 0 ;;
--) shift; break ;;
*) die 70 "internal error: $1" ;;
esac
done
echo "verbose=$verbose dry=$dry count=$count out=[$out] files=($#): $*"
$ bash o1.sh -vn 2 --output=o.txt --dry-run "a b" c
verbose=1 dry=1 count=2 out=[o.txt] files=(2): a b c
$ bash o1.sh --cou 5 x
verbose=0 dry=0 count=5 out=[] files=(1): x
$ bash o1.sh x -v y
verbose=1 dry=0 count=1 out=[] files=(2): x y
$ bash o1.sh -- -v
verbose=0 dry=0 count=1 out=[] files=(1): -v
$ bash o1.sh --nope
o1.sh: unrecognized option '--nope'
$ bash o1.sh -n
o1.sh: option requires an argument -- 'n'
Everything the simple loop could not do works here: bundling with a value (-vn 2), "a b" kept as one argument, a long option abbreviated (--cou for --count: convenient for typing, but then you can never rename an option safely, so many people disable it by not documenting it), options after plain arguments ("x -v y"), and -- ending the options. getopt prints its own error messages with the program name you gave it (the two at the end) and exits with status 1 or 2 itself, which is why the script checks "if ! parsed=$(getopt ...); then exit 2". Do not skip that status check: after a bad option getopt still prints a partial result on stdout (for getopt -o a -- -a -z foo it printed -a -- 'foo' and returned status 1, with the complaint "invalid option -- 'z'" on stderr), so a script that ignores the status carries on with a half-parsed command line.
The eval is safe here because getopt quotes everything it returns (a file name with spaces or quotes survives), but it is the one place where you must not cut corners: always eval set -- "$parsed" with the quotes, exactly as written.
Choosing between them
positional only ($1, $2) one or two required arguments and no options: ${1:?usage} is enough
getopts only short options, a small script, must run on any POSIX shell (it is also in dash/sh); you accept: no long options, options before arguments
while/case loop long options and --opt=value, no dependency, you need to control every message; you accept: no bundling unless you write it
GNU getopt (util-linux) full GNU behaviour: bundling, long options, abbreviations, options anywhere; you accept: Linux-only assumption (check getopt -T) and the eval step
a real language many options, subcommands, types and help generation: use Python (argparse, click) or Go instead; a shell parser over 100 lines is a sign to switch
The standard conventions to follow in any of them: -h and --help print usage to stdout and exit 0; wrong usage prints a short message to stderr and exits 2; -- ends options; a lone - means "standard input" where a file is expected; -v/--verbose, -q/--quiet, -n/--dry-run and -f/--force have the meanings people expect.
Subcommands (git style)
Tools such as git, docker and systemctl take a verb first and options after it. The pattern is simple: take the verb with shift, then pass the rest to a function named after it:
$ cat s1.sh
#!/bin/bash
set -euo pipefail
usage() { echo "usage: s1.sh {start|stop|status} [args]"; }
cmd_start() { echo "starting ${1:-all}"; }
cmd_stop() { echo "stopping ${1:-all}"; }
cmd_status() { echo "status: ok"; }
[[ $# -ge 1 ]] || { usage >&2; exit 2; }
sub=$1; shift
case $sub in
start|stop|status) "cmd_$sub" "$@" ;;
-h|--help|help) usage ;;
*) echo "s1.sh: unknown command '$sub'" >&2; usage >&2; exit 2 ;;
esac
$ bash s1.sh start web
starting web
$ bash s1.sh restart
s1.sh: unknown command 'restart'
usage: s1.sh {start|stop|status} [args]
$ bash s1.sh; echo "[exit $?]"
usage: s1.sh {start|stop|status} [args]
[exit 2]
Each cmd_ function can run its own getopts or loop on its remaining arguments (with local OPTIND). The case lists the allowed verbs explicitly, so a user-supplied word can never name an arbitrary function: calling "cmd_$sub" for ANY input would let someone run any function whose name starts with cmd_, and a case guard keeps that list under your control.
Validating values and required options
Parsing only separates the pieces; checking them is your job, and it is where scripts become safe. Check early, say which option was wrong and what was expected, and exit 2:
$ cat m1.sh
#!/bin/bash
host=""; port=22
while getopts ":H:p:" o; do case $o in H) host=$OPTARG;; p) port=$OPTARG;; *) echo "bad option" >&2; exit 2;; esac; done
: "${host:?-H HOST is required}"
case $port in (*[!0-9]*|'') echo "m1.sh: port must be a number" >&2; exit 2;; esac
(( port >= 1 && port <= 65535 )) || { echo "m1.sh: port out of range: $port" >&2; exit 2; }
echo "connecting to $host:$port"
$ bash m1.sh; echo "[exit $?]"
m1.sh: line 4: host: -H HOST is required
[exit 1]
$ bash m1.sh -H web1 -p 99999
m1.sh: port out of range: 99999
$ bash m1.sh -H web1 -p ssh
m1.sh: port must be a number
$ bash m1.sh -H web1 -p 2222
connecting to web1:2222
Three things in this little script: the case pattern (*[!0-9]*|'') rejects anything that is not all digits (and the empty string) BEFORE the arithmetic test, because (( )) on a non-number would treat the text as a variable name, or fail with a syntax error; the range check follows; and ${host:?message} is a one-line "required" check. It works, but it exits with status 1 (not 2) and an error prefixed with the script and line number ("m1.sh: line 4: host:"). A friendlier check is [[ -n $host ]] || die 2 "-H HOST is required".
Common checks: [[ -f $file && -r $file ]] (a readable regular file), [[ -d $dir ]], [[ $n =~ ^[0-9]+$ ]] (digits only), [[ $mode == @(fast|slow) ]] (one of a list; the @( ) pattern works inside [[ ]] even with extglob off), and for paths that start with a dash, always use -- before them when you call other tools: cp -- "$src" "$dst".
Defaults, config files, environment variables and precedence
Settings can come from four places. The convention (the same as most Unix tools) is: built-in default, then a config file, then an environment variable, then the command-line option, with the later one winning. It takes four lines:
$ cat c1.sh
#!/bin/bash
set -euo pipefail
LOG_LEVEL=info # 1. built-in default
[[ -r ./c1.conf ]] && source ./c1.conf # 2. config file
LOG_LEVEL=${APP_LOG_LEVEL:-$LOG_LEVEL} # 3. environment
while getopts ":l:" o; do [[ $o == l ]] && LOG_LEVEL=$OPTARG; done # 4. flag wins
echo "LOG_LEVEL=$LOG_LEVEL"
$ bash c1.sh
LOG_LEVEL=info
$ echo 'LOG_LEVEL=warn' > c1.conf
$ bash c1.sh
LOG_LEVEL=warn
$ APP_LOG_LEVEL=debug bash c1.sh
LOG_LEVEL=debug
$ APP_LOG_LEVEL=debug bash c1.sh -l error
LOG_LEVEL=error
Every level overrode the one before it. Two warnings: source runs the config file as shell code, so only read a file that only the right user can write (check the owner and the permissions first), never one in a world-writable folder; and give your environment variables a prefix (APP_...) so they do not clash with the system's. For a secret, prefer a file with mode 600 or a protected environment, and not a command-line option: arguments are visible to every user in ps and in /proc/PID/cmdline.
Reading from a file or from stdin
Filter-style scripts should accept a file name or standard input, with - meaning stdin. The test [[ -t 0 ]] tells whether stdin is a terminal, so you can print a hint instead of hanging while the user wonders why nothing happens:
$ cat i1.sh
#!/bin/bash
set -euo pipefail
src=${1:--}
if [[ $src == - ]]; then
[[ -t 0 ]] && { echo "i1.sh: reading from the terminal, press Ctrl+D to finish" >&2; }
input=$(cat)
else
input=$(<"$src")
fi
echo "got ${#input} bytes, $(printf '%s\n' "$input" | wc -l) line(s)"
$ printf 'one\ntwo\n' | bash i1.sh
got 7 bytes, 2 line(s)
$ printf 'one\ntwo\nthree\n' > in.txt; bash i1.sh in.txt
got 13 bytes, 3 line(s)
$ bash i1.sh < in.txt
got 13 bytes, 3 line(s)
The first run counted 7 bytes for "one\ntwo\n" (8 bytes in reality): the command substitution $(cat) removes trailing newlines, a behaviour that matters whenever you compare or write the value back. For big inputs do not load everything into a variable: stream it with while IFS= read -r line; do ...; done < "$src" (with src set to /dev/stdin for -).
Usage messages and exit codes
Print the usage in one place, from a function, and use it for -h/--help (stdout, exit 0) and for mistakes (stderr, exit 2). A here-document with a quoted delimiter (<<'USAGE') prints the text literally, so dollar signs and quotes in the examples are not expanded. Include: a one-line synopsis, one line per option, the exit codes, and one example. Keep the option text next to the code that parses it, so they stay in sync (some scripts even generate the help by grepping their own source for "#:" comment lines). Exit codes: 0 success and --help, 2 wrong usage, 1 any other error, and your own small numbers for specific failures.
Common problems
-v after a file name is ignored getopts stops at the first non-option: put options first, or use GNU getopt or a while/case loop
--verbose is "unknown option -" getopts has no long options: use a loop or getopt
-o -v takes -v as the file name getopts takes whatever follows an option that needs a value: check [[ $OPTARG == -* ]]
The second call of a parsing function does nothing OPTIND was not reset: local OPTIND in the function, or OPTIND=1 before the loop
A file name with spaces is split into pieces $* or $@ without quotes, or a string used as an argument list: use "$@" and arrays
A file called -rf or -n is taken as an option add -- before the names (rm -- "$f"), and tell users to pass -- to your script
"unbound variable" when the user passes no arguments set -u with $1: use ${1:-} or check $# first
shift: shift count out of range, or silent failure you shifted more than $#: test (( $# >= 2 )) before shift 2, as in the examples
getopt: eval gives odd results or "unrecognized option" the old BSD getopt: check getopt -T; or the eval was written without the quotes: eval set -- "$parsed"
Options work interactively but not from cron cron passes the same arguments, but with a minimal PATH and no terminal: use full paths and never wait for keyboard input
The default overrides the flag the order of precedence is wrong: set defaults first, then config, environment, and the flags last
Numbers like 08 or 09 give "value too great for base" (( )) and $(( )) read a leading 0 as octal: force base ten with $((10#$n)) after validating that it is all digits
Tips
Quote "$@", always. Unquoted $* and $@ throw away the boundaries between arguments.
Pass argument lists around in arrays, not in strings.
Use getopts for simple short options, a while/case loop for long ones, GNU getopt when you need the full GNU behaviour, and a real language when the parser grows past about a hundred lines.
Start the option string with a colon (":vo:n:h") and write your own error messages.
Make OPTIND local in any function that calls getopts.
Validate every value (number, range, path, choice) right after parsing and exit 2 with a message that names the option.
Support -h/--help, --, and - for stdin, and keep the usage text next to the code that parses the options.
Exit 0 for success and --help, 2 for wrong usage, 1 for other errors; never print usage errors to stdout.
Test the bad inputs on purpose: no arguments, an unknown option, a missing value, a value that looks like an option, a file name with spaces, and a file name starting with a dash.