A script that reads $1 and $2 positionally is a script only its author can use. getopts is in every POSIX shell, needs no dependencies, and gives you the interface conventions users already know.
Topic 1: How getopts Works
while getopts ":vo:h" opt; do
case $opt in
v) verbose=1 ;;
o) output=$OPTARG ;;
h) usage; exit 0 ;;
\?) echo "unknown option: -$OPTARG" >&2; usage >&2; exit 2 ;;
:) echo "option -$OPTARG requires an argument" >&2; exit 2 ;;
esac
done
shift $(( OPTIND - 1 ))
# "$@" now holds only the non-option arguments
The option string:
- A bare letter (
v) is a flag. - A letter followed by
:(o:) takes an argument, delivered in$OPTARG. - A leading
:switches on silent error reporting —getoptsstops printing its own messages and instead reports unknown options as\?and missing arguments as:. Always use it; it is the only way to control your own error output.
The two variables:
| Variable | Holds |
|---|---|
$OPTARG | The argument to the current option |
$OPTIND | Index of the next argument to process |
shift $(( OPTIND - 1 )) discards everything getopts consumed, leaving the positional arguments in "$@". Forgetting that line is the single most common getopts bug — the flags stay in $@ and every downstream loop sees them as filenames.
What you get for free:
./tool -v -o out.txt file1 file2 # separate
./tool -vo out.txt file1 file2 # bundled
./tool -vooutput.txt file1 # attached argument
./tool -v -- -weird-filename # -- ends option parsing
All four work without extra code. That is why getopts is worth using over hand-rolled case loops on $1.
Topic 2: A Complete Skeleton
#!/usr/bin/env bash
set -Eeuo pipefail
readonly SCRIPT_NAME=${0##*/}
readonly VERSION="1.2.0"
# Defaults, overridable by environment then flags
verbose=0
output=""
dry_run=0
retries=${TOOL_RETRIES:-3}
usage() {
cat <<EOF
${SCRIPT_NAME} ${VERSION} — sync build artifacts to a target host
USAGE
${SCRIPT_NAME} [OPTIONS] FILE...
OPTIONS
-o FILE write output to FILE (default: stdout)
-r N retry attempts on failure (default: ${retries})
-n dry run; print actions without performing them
-v verbose; repeat for more detail
-h show this help and exit
-V print version and exit
ENVIRONMENT
TOOL_RETRIES default for -r
EXAMPLES
${SCRIPT_NAME} -v -o report.txt build/*.log
${SCRIPT_NAME} -n build/app.log
EXIT STATUS
0 success
1 runtime failure
2 usage error
EOF
}
die() { echo "${SCRIPT_NAME}: $*" >&2; exit 1; }
while getopts ":o:r:nvhV" opt; do
case $opt in
o) output=$OPTARG ;;
r) retries=$OPTARG ;;
n) dry_run=1 ;;
v) (( verbose++ )) || true ;;
h) usage; exit 0 ;;
V) echo "${SCRIPT_NAME} ${VERSION}"; exit 0 ;;
\?) echo "${SCRIPT_NAME}: unknown option -${OPTARG}" >&2; usage >&2; exit 2 ;;
:) echo "${SCRIPT_NAME}: -${OPTARG} requires an argument" >&2; exit 2 ;;
esac
done
shift $(( OPTIND - 1 ))
# Validate what is left
(( $# > 0 )) || { echo "${SCRIPT_NAME}: no input files" >&2; usage >&2; exit 2; }
[[ $retries =~ ^[0-9]+$ ]] || die "-r must be a number, got: ${retries}"
for f in "$@"; do
[[ -r $f ]] || die "cannot read: ${f}"
done
Three details worth copying:
-hexits 0 and prints to stdout; a usage error prints to stderr and exits 2. Help that was asked for is not an error. Users pipe--helpintoless; they cannot if it goes to stderr.- Exit 2 for usage errors is the long-standing Unix convention, distinct from 1 for runtime failure. It lets a caller distinguish “you invoked me wrong” from “the work failed”.
-vcounted, not boolean.(( verbose++ ))lets-vvmean more detail, matchingssh -vvv.
Topic 3: Long Options
getopts handles short options only. Bash has no built-in long-option parser, and there are three honest choices.
1. Hand-rolled while/case — full control, more code:
while [[ $# -gt 0 ]]; do
case $1 in
-o|--output) output=$2; shift 2 ;;
--output=*) output=${1#*=}; shift ;;
-n|--dry-run) dry_run=1; shift ;;
-v|--verbose) (( verbose++ )) || true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*) break ;;
esac
done
Note the --output=value case: users expect both spellings. And -- must break the loop so a file literally named -v can be passed.
2. GNU getopt(1) — the external program, not the builtin:
parsed=$(getopt -o o:nvh --long output:,dry-run,verbose,help -n "$SCRIPT_NAME" -- "$@") || exit 2
eval set -- "$parsed"
Handles long options and reorders arguments, but it is GNU-specific — macOS ships the BSD version, which does not support --long and silently behaves differently. Only use it if you control the platform.
3. Decide it is Python’s problem. Past a handful of options with interdependencies, argparse is a better tool than either of the above.
Topic 4: Subcommands
For anything with more than one verb, dispatch on $1 and forward the rest:
usage() {
cat <<EOF
USAGE: ${SCRIPT_NAME} <command> [options]
COMMANDS
deploy push the current build to a target
rollback restore the previous release
status show what is currently deployed
Run '${SCRIPT_NAME} <command> -h' for command-specific help.
EOF
}
cmd_deploy() {
local target=""
while getopts ":t:h" opt; do
case $opt in
t) target=$OPTARG ;;
h) echo "usage: ${SCRIPT_NAME} deploy -t TARGET"; return 0 ;;
\?) echo "deploy: unknown option -${OPTARG}" >&2; return 2 ;;
esac
done
shift $(( OPTIND - 1 ))
[[ -n $target ]] || { echo "deploy: -t is required" >&2; return 2; }
echo "deploying to ${target}"
}
cmd_status() { echo "status"; }
cmd_rollback() { echo "rollback"; }
main() {
(( $# > 0 )) || { usage >&2; exit 2; }
local subcommand=$1; shift
case $subcommand in
deploy|rollback|status) "cmd_${subcommand}" "$@" ;;
-h|--help|help) usage; exit 0 ;;
*) echo "${SCRIPT_NAME}: unknown command '${subcommand}'" >&2; usage >&2; exit 2 ;;
esac
}
main "$@"
Two things make this work:
OPTINDmust be reset if you callgetoptsmore than once in a run —local OPTIND=1inside eachcmd_*function does it, sincelocalgives each invocation its own copy.- The
caseallow-list before dispatch. Calling"cmd_${subcommand}"directly on unvalidated input would let a user invoke any function in your script by name.
Topic 5: Interface Conventions Users Expect
| Convention | Why |
|---|---|
-h/--help to stdout, exit 0 | So it can be piped |
--version | Scripts and humans both check it |
| Errors to stderr | So stdout stays parseable |
| Exit 0 success, 1 failure, 2 usage | Callers branch on it |
-- ends options | The only way to pass a file named -x |
-n/--dry-run on anything destructive | Users will trust the tool faster |
Respect NO_COLOR | An accessibility convention worth honouring |
Detect a pipe with [[ -t 1 ]] | Colour on a terminal, plain when piped |
# Colour only when stdout is a terminal and NO_COLOR is unset
if [[ -t 1 && -z ${NO_COLOR:-} ]]; then
readonly C_RED=$'\033[31m' C_RESET=$'\033[0m'
else
readonly C_RED="" C_RESET=""
fi
echo "${C_RED}error${C_RESET}: something failed" >&2
[[ -t 1 ]] is true when fd 1 is a terminal. Without this check, escape codes end up in log files as ^[[31m, which is the reason so many CI logs are unreadable.
Making it installable:
chmod +x bin/deploy
sudo install -m 0755 bin/deploy /usr/local/bin/deploy
With no extension and a proper usage, it is now indistinguishable from any other tool on the box — which is the point.
Try it yourself: Add -h, --version, and [[ -t 1 ]] colour handling to a script you already use, then run ./script -h | cat and confirm the help still appears with no escape codes.
Common mistake: Omitting shift $(( OPTIND - 1 )). Everything appears to work while you test with flags only, then the first positional argument turns out to be -v and the loop tries to open it as a file.