Command-line options

getopts, long options, --help, -- and a dry-run that changes nothing.

Beginner25 min · lesson 10 of 15
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (5 files, 1 KB): bo-args.tar.gz. Unpack it with tar -xzf bo-args.tar.gz, which creates bo-args/. SHA-256: 1eeb05d4e96395d949b696d78e08bd57d7d48d29d795015a07563ef6ee791dbe

Ops scripts need settings: how many days of logs to keep, whether to only show what they would do, which directories to work on. Options are how people pass those settings, and a script that handles them carelessly can do the wrong thing on a typo. In this lesson you will parse short options with getopts, parse long options such as --dry-run with a while and case loop, print --help, reject a bad command line with a message and status 2 before anything happens, make -- end the options, validate option values, and prove that a dry run changes nothing. It also sets out the exit-status contract this course uses for Bash scripts. The examples run in ~/bash-ops/args.

Options, operands and the exit-status contract

Most Unix commands follow the conventions in POSIX's Utility Syntax Guidelines. An option starts with a dash: -n. Some options take a value, called an option-argument: -d 7, or -d7 written together. Options without values can be grouped, so -nv means -n -v. Operands are the other arguments, usually files or directories. -- ends the options: everything after it is an operand, even when it starts with a dash. GNU tools add long options with two dashes (--dry-run, and --days 7 or --days=7 for a value) and also accept options after operands.

A script that follows these conventions should also follow an exit-status contract, so that the scheduler, pipeline or person running it can react without reading its messages:

The exit-status contract for this course's Bash scripts
Exit status
what the caller should do
0
success
the job was done (a dry run that listed its plan is a success)
1
failure
the job could not be done, or found what it checks for; alert or investigate
2
usage error
the command line is wrong and nothing was done; fix it, a retry will not help
3 and up
documented extras
only if listed in --help, for example "another run holds the lock"
Statuses from 126 up belong to the shell (126, 127 and 128 + a signal number), so keep your own below them.

getopts: short options

getopts is a shell builtin (POSIX, so it works in Dash too) that parses short options one at a time. It runs as the condition of a while loop, with an option string that lists the letters it accepts; a letter followed by : takes a value. This script only reports what it parsed:

show-opts.sh
#!/usr/bin/env bash
# show-opts.sh: parse -n, -d DAYS and -h with getopts, then show what was parsed.
dry_run=0
days=14
while getopts ':nd:h' opt; do
case $opt in
n) dry_run=1 ;;
d) days=$OPTARG ;;
h)
printf 'usage: show-opts.sh [-n] [-d DAYS] DIR...\n'
exit 0
;;
:)
printf 'show-opts.sh: option -%s needs a value\n' "$OPTARG" >&2
exit 2
;;
'?')
printf 'show-opts.sh: unknown option -%s\n' "$OPTARG" >&2
exit 2
;;
esac
done
shift $((OPTIND - 1))
printf 'dry_run=%s days=%s, %s operand(s):' "$dry_run" "$days" "$#"
printf ' [%s]' "$@"
printf '\n'

':nd:h' accepts -n, -d VALUE and -h. Each pass of the loop puts the letter in opt and any value in OPTARG. The leading : switches getopts to silent mode: instead of printing its own error, it sets opt to : when a value is missing and to ? for an unknown option, with the letter in OPTARG, so the script prints its own message and exits 2. The '?' pattern is quoted because an unquoted ? in case matches any single character. When the options run out, getopts returns non-zero and the loop ends. OPTIND then holds the position of the first argument it did not process, so shift $((OPTIND - 1)) removes the options and leaves the operands in $@.

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ chmod +x show-opts.sh ./show-opts.sh -n -d 7 logs ./show-opts.sh -nd7 logs 'old logs'
dry_run=1 days=7, 1 operand(s): [logs] dry_run=1 days=7, 2 operand(s): [logs] [old logs]

-nd7 was taken apart into -n and -d 7, and the operand with a space in it arrived whole.

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ ./show-opts.sh -d echo "status: $?" ./show-opts.sh -x logs echo "status: $?"
show-opts.sh: option -d needs a value status: 2 show-opts.sh: unknown option -x status: 2

Both mistakes produced a message on standard error and status 2, and the script did nothing else.

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ ./show-opts.sh logs -n ./show-opts.sh -- -n
dry_run=0 days=14, 2 operand(s): [logs] [-n] dry_run=0 days=14, 1 operand(s): [-n]

getopts stops at the first operand, as POSIX specifies, so in logs -n the -n became a second operand and dry_run stayed 0. -- ended the options, so the -n after it is an operand, which is how you pass a name that starts with a dash.

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ ./show-opts.sh --dry-run logs echo "status: $?"
show-opts.sh: unknown option -- status: 2

getopts has no long options. It read --dry-run as the option - (unknown) followed by more letters, and the script stopped. For scripts with only short options, getopts is the right tool; for long options, you write the loop yourself.

Long options with a while and case loop

prune-logs.sh deletes old .log files from the directories it is given. It takes short and long options and is long enough to read in three parts. Part 1 holds the help text and the error helper:

prune-logs.sh, part 1 of 3
#!/usr/bin/env bash
# prune-logs.sh: delete *.log files older than DAYS days directly inside each DIR.
# Exit status: 0 done, 1 a directory was missing or a deletion failed, 2 usage error.
usage() {
cat <<'END'
Usage: prune-logs.sh [OPTIONS] [--] DIR...
Delete *.log files older than DAYS days directly inside each DIR.
-d, --days DAYS age limit in whole days, 1 to 3650 (default 14)
-n, --dry-run list what would be deleted; delete nothing
-h, --help show this help and exit
Exit status: 0 done, 1 a directory was missing or a deletion failed, 2 usage error.
END
}
usage_error() {
printf 'prune-logs.sh: %s\n' "$1" >&2
printf "Try 'prune-logs.sh --help' for more information.\n" >&2
exit 2
}

usage prints the help with a quoted here-document (<<'END', so nothing inside is expanded). usage_error prints the problem and a hint on standard error and exits 2; every command-line mistake goes through it, so they all look and behave alike. Part 2 is the parser:

prune-logs.sh, part 2 of 3
days=14
dry_run=0
dirs=()
while (( $# > 0 )); do
case $1 in
-h | --help)
usage
exit 0
;;
-n | --dry-run) dry_run=1 ;;
-d | --days)
(( $# >= 2 )) || usage_error "option $1 needs a value"
days=$2
shift
;;
--days=*) days=${1#--days=} ;;
--)
shift
dirs+=("$@")
break
;;
-?*) usage_error "unknown option: $1" ;;
*) dirs+=("$1") ;;
esac
shift
done

The loop looks at $1, handles it, and shifts it away until no arguments remain. -n only sets a variable. For an option with a value, the branch first checks with (( $# >= 2 )) that a value follows, takes $2, and shifts once more so the value is not read as the next option. --days=* handles the --days=7 spelling with the # trimming from the previous lesson. -- adds every remaining argument to dirs as an operand. -?* (a dash and at least one more character) catches every other option as unknown. Anything else is an operand, collected in the dirs array.

Unlike getopts, this loop keeps reading options after an operand, as GNU tools do. For a command that deletes things this matters: someone who types prune-logs.sh logs --dry-run expects a dry run. A parser that stopped at logs would take --dry-run for a directory, delete the old logs in logs, and only then complain. The value check matters too, because shift refuses to shift more arguments than there are:

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ set -- --days days=$2 shift 2 echo "shift status: $?, \$1 is still [$1]"
shift status: 1, $1 is still [--days]

shift 2 with one argument left failed and shifted nothing, so $1 was still --days. A loop that ended its --days branch with a plain shift 2 would meet the same argument again, forever. The check turns that case into a usage error. Part 3 validates and does the work:

prune-logs.sh, part 3 of 3
if ! [[ $days =~ ^[1-9][0-9]{0,3}$ ]] || (( days > 3650 )); then
usage_error "--days must be a whole number from 1 to 3650, not '$days'"
fi
(( ${#dirs[@]} > 0 )) || usage_error "no directory given"
status=0
for dir in "${dirs[@]}"; do
path=$dir
[[ $path == /* ]] || path=./$path # find reads -old as a test even after --; ./-old is a path
if [[ ! -d $path ]]; then
printf 'prune-logs.sh: not a directory: %s\n' "$dir" >&2
status=1
continue
fi
if (( dry_run )); then
find "$path" -maxdepth 1 -type f -name '*.log' -mtime +"$days" -printf 'would delete %p\n'
else
find "$path" -maxdepth 1 -type f -name '*.log' -mtime +"$days" -printf 'deleting %p\n' -delete || status=1
fi
done
exit "$status"

The values are checked before anything uses them: --days must be a whole number from 1 to 3650, matched with a regular expression before (( )) sees it (the rule from "Exit status and tests"), and at least one directory must be given. find reads any argument that starts with - as the start of its search expression, even after --, so it would take a directory named -old for one of its tests; a relative path therefore gets ./ in front, the fix from the quoting lesson. -mtime +DAYS selects files whose age, rounded down to whole days, is more than DAYS, so a file must be at least DAYS+1 whole days old: with --days 14, a file 14.9 days old is kept and one 15.1 days old is deleted (the lab checked both). -maxdepth 1 stays in the directory itself, and -delete removes what matched. A missing directory is reported and skipped with status 1, so one bad operand does not stop the others. The whole file is the three parts in order. Now some test data, then the help:

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ mkdir logs touch -d '30 days ago' logs/app-old.log 'logs/old report.log' logs/old-notes.txt touch -d '3 days ago' logs/app-recent.log touch logs/app.log find logs -type f -printf '%TF %p\n' | sort
2026-08-29 logs/app-old.log 2026-08-29 logs/old report.log 2026-08-29 logs/old-notes.txt 2026-09-25 logs/app-recent.log 2026-09-28 logs/app.log
deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ chmod +x prune-logs.sh ./prune-logs.sh --help echo "status: $?"
Usage: prune-logs.sh [OPTIONS] [--] DIR... Delete *.log files older than DAYS days directly inside each DIR. -d, --days DAYS age limit in whole days, 1 to 3650 (default 14) -n, --dry-run list what would be deleted; delete nothing -h, --help show this help and exit Exit status: 0 done, 1 a directory was missing or a deletion failed, 2 usage error. status: 0

The help goes to standard output with status 0, because the user asked for it: it can be piped into less or grep like any other output.

Usage errors stop the script before it acts

A usage error should stop the script before it touches anything, with a message that says what was wrong. Here are four common mistakes, each followed by its status:

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ ./prune-logs.sh --dry-rn logs echo "status: $?" ./prune-logs.sh logs --days echo "status: $?" ./prune-logs.sh --days 7x logs echo "status: $?" ./prune-logs.sh -n echo "status: $?"
prune-logs.sh: unknown option: --dry-rn Try 'prune-logs.sh --help' for more information. status: 2 prune-logs.sh: option --days needs a value Try 'prune-logs.sh --help' for more information. status: 2 prune-logs.sh: --days must be a whole number from 1 to 3650, not '7x' Try 'prune-logs.sh --help' for more information. status: 2 prune-logs.sh: no directory given Try 'prune-logs.sh --help' for more information. status: 2

Every mistake was caught before any file was touched, each with its own message, the same hint and status 2. The first one deserves attention: a typo in the very option that makes a run safe. A parser that skipped unknown options would have gone on and deleted files. logs --days shows the value check at work, now as a clean usage error. The status is the part a machine reads:

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ ./prune-logs.sh --dayz 14 logs 2>/dev/null case $? in 0) echo "ok" ;; 1) echo "the job failed: alert someone" ;; 2) echo "called wrongly: fix the command line, a retry will not help" ;; *) echo "undocumented status: treat it as a failure" ;; esac
called wrongly: fix the command line, a retry will not help

The message was thrown away, and the caller still knew what kind of failure it was: 2 says that retrying cannot help and a person must fix the command, while 1 would mean the job itself failed. Document every status in the script's header and in --help, as prune-logs.sh does, and never use 2 for a failure that running the job again could fix: 2 tells the caller that a person must change how the command is run. (Some tools, grep among them, also use 2 for an input they cannot use at all; the Python course does the same. The meaning for the caller does not change.)

--dry-run must change nothing

A dry run is only useful if it is honest: it must select exactly what a real run would, and change nothing. Check that instead of assuming it, by recording the files before and after and comparing:

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ find logs -type f | sort > before.txt ./prune-logs.sh logs --dry-run find logs -type f | sort > after.txt diff before.txt after.txt && echo "dry run changed nothing"
would delete ./logs/old report.log would delete ./logs/app-old.log dry run changed nothing

diff found no difference, and the two files listed are the .log files older than 14 days. --dry-run came after the directory and still counted. old-notes.txt is just as old but is not a .log file, and the recent logs are too new.

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ ./prune-logs.sh --days=14 logs echo "status: $?" find logs -type f | sort
deleting ./logs/old report.log deleting ./logs/app-old.log status: 0 logs/app-recent.log logs/app.log logs/old-notes.txt

The real run deleted exactly the files the dry run listed. Both runs use the same find selection and differ only in the last action, which is what keeps the dry run honest. A dry run with its own separate listing code can drift away from what the real run deletes. Finally, -- for a directory whose name starts with a dash:

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ mkdir -- -old touch -d '30 days ago' -- -old/app.log ./prune-logs.sh -n -old echo "status: $?" ./prune-logs.sh -n -- -old echo "status: $?"
prune-logs.sh: unknown option: -old Try 'prune-logs.sh --help' for more information. status: 2 would delete ./-old/app.log status: 0

Without --, -old was an unknown option: a usage error, and nothing happened. After -- it was a directory, and the script's own ./ kept find from misreading it.

deploy@web01:~/bash-ops/args · Ubuntu 26.04 LTS
$ ./prune-logs.sh -- logs missing -old echo "status: $?"
prune-logs.sh: not a directory: missing deleting ./-old/app.log status: 1

One directory did not exist: the script said so, still processed the others, and exited 1. The job was only partly done, and the status says so.

Try this

A wrong pattern or --days value can match far more files than you meant. Add a safety brake to a copy of prune-logs.sh: -m N, --max N and --max=N (default 100). When more than N files match in a directory, delete nothing there, print prune-logs.sh: DIR: COUNT files match, more than --max N; deleting nothing to standard error, and set status 1. Validate the value with usage_error: a whole number from 1 to 999999, checked with a regular expression capped at six digits as --days is (^[1-9][0-9]{0,5}$), and add the option to the help text and to the exit-status lines. To count matches whatever the file names contain, run the same find with -printf '.' (one dot per file) and count the dots with wc -c, then test (( count > max )) before the dry-run branch. With three .log files older than 14 days in logs, one of them with a space in its name:

1. ./prune-logs.sh --max 2 logs: expect prune-logs.sh: logs: 3 files match, more than --max 2; deleting nothing, status 1, and all three files still there.

2. ./prune-logs.sh --max 0 logs: expect --max must be a whole number from 1 to 999999, not '0' (or your own wording), the hint, and status 2.

3. ./prune-logs.sh --max=5 logs: expect three deleting lines, status 0, and an empty logs.

Takeaway

Parse and validate every option before the script acts, send usage errors to standard error with status 2, keep 1 for a job that failed, and document each status. Give --dry-run the same selection as the real run so that it shows exactly what would happen and changes nothing, and accept -- so that any operand can be passed safely.

Quick check
01A script parses its options with getopts ':nd:'. An operator runs ./cleanup.sh /var/app/logs -n, meaning a dry run, and the script deletes files. Why?
Correct — POSIX option parsing ends at the first operand. Put options first, or write a loop that collects operands and keeps reading options, as prune-logs.sh does.
Incorrect — getopts handles separate and grouped options alike; -n -d 7 worked in the lesson.
Incorrect — The leading : only switches error reporting to silent mode, so the script can print its own messages.
Incorrect — A colon applies to the letter just before it: d: takes a value and n does not.
02A nightly job's wrapper retries any failed run up to three times. Tonight the script exited 2, because the crontab line says --day 7 instead of --days 7. What should the wrapper do with status 2?
Incorrect — The command line is the same on every attempt, so every retry fails the same way and only delays the alert.
Incorrect — Nothing was deleted, but nothing was cleaned either: the job did not do its work, and a person has to know.
Correct — Status 2 means a usage error. A person has to correct the crontab line; retries are for 1, and only when the failure may be temporary.
Incorrect — The same wrong option is still there, so the dry run exits 2 as well. The message on standard error already says what is wrong.
03In a cleanup script, the --dry-run branch prints would delete for every result of ls *.log, while the real branch runs find . -name '*.log' -mtime +14 -delete. What is wrong with this design?
Incorrect — Changing nothing is only half of an honest dry run; it must also show what a real run would do, and this one does not.
Correct — The two branches select files differently, so the dry run's plan is not the real run's plan. Share one selection and change only the final action.
Incorrect — ls *.log lists such names (the glob passes them whole). The problem is which files are chosen, not how they are printed.
Incorrect — A dry run that did what was asked, showing its plan, is a success and exits 0. Status 1 would make every dry run look like a failed job.

Related