Skip to content
shellhacks.dev
Shell Scripting

How to Add Verbose and Quiet Flags to a Shell Script

Add verbose and quiet flags to a shell script with getopts, log levels, stderr for messages, and a debug trace, so output stays useful and pipe-friendly.

6 min read
Summarize in AI
Illustration for "How to Add Verbose and Quiet Flags to a Shell Script": a shell script in an editor, a scrolling log, a disk usage gauge.

A script with one fixed amount of output is wrong for half its users: too chatty in cron, too silent when something fails. Two flags fix this, and the design takes a few lines. It builds on designing a command line interface, and the examples assume Bash 5.2 and its builtin getopts.

Parse the flags

Shell
#!/usr/bin/env bash
VERBOSITY=1   # 0 quiet, 1 normal, 2 verbose, 3 debug

while getopts ":vqx" opt; do
  case $opt in
    v) (( VERBOSITY++ )) ;;
    q) VERBOSITY=0 ;;
    x) set -x ;;
    *) echo "usage: $0 [-v] [-q] [-x]" >&2; exit 2 ;;
  esac
done
shift $(( OPTIND - 1 ))

The leading colon in the option string makes getopts report unknown options quietly so your * branch handles them. A repeated -vv increments twice. exit 2 is the conventional status for a usage error, covered in designing exit codes.

A single log function

Shell
log() {
  local level=$1; shift
  (( VERBOSITY >= level )) && printf '%s\n' "$*" >&2
  return 0
}

log 1 "starting backup"
log 2 "scanning /srv/data"
log 3 "rsync options: -a --delete"
Text
$ ./backup.sh
starting backup
$ ./backup.sh -vv
starting backup
scanning /srv/data
$ ./backup.sh -q

Level 1 is the default, so the script reports its start and failures. Every message goes to stderr. The explicit return 0 matters under set -e: the && chain would otherwise return 1 when the message is suppressed, and the script would exit on a log line.

Errors are never quiet

Shell
die() {
  printf 'error: %s\n' "$*" >&2
  exit 1
}

[[ -d $1 ]] || die "not a directory: $1"

A quiet flag silences progress, not failures. Errors always print, and the exit status is the signal for automation. Making -q hide errors turns a broken cron job into one that fails with no trace. Keep the useful result on stdout, such as a path or a count, so dir=$(./make-build.sh -q) captures exactly that and nothing else.

Colour and terminals

Shell
if [[ -t 2 && -z ${NO_COLOR:-} ]]; then
  RED=$'\033[31m'; RESET=$'\033[0m'
else
  RED=""; RESET=""
fi
printf '%serror%s: disk full\n' "$RED" "$RESET" >&2

Only colour when stderr is a terminal, and honour the NO_COLOR convention documented at no-color.org. Escape codes in a log file are noise that makes it harder to search. For tracing, set -x prints each command after expansion to stderr, and setting PS4='+ ${BASH_SOURCE}:${LINENO}: ' adds file and line numbers, as shown in logging in shell scripts.

Choosing the default level

The default verbosity is a product decision, and the right answer depends on how the script usually runs. A tool people run by hand wants a short progress line per major step, so that a long operation does not look hung. A job that runs from cron wants near silence on success, because cron mails any output to the owner and a daily "backup finished" message trains everyone to ignore the mailbox. The sensible compromise is a default of one line per step on a terminal, with errors always printed, and a -q option that cron entries can use.

A script can also pick its default from its surroundings. Testing whether stderr is a terminal with [[ -t 2 ]] lets one script talk to a person and stay quiet under cron without any flag. Explicit flags should still override the guess, so that a person can run -q by hand and a cron entry can run -v when debugging a failure.

Separating results from messages

The streams matter more than the levels. Standard output is for the result, the thing another program might consume: a path, a list of file names, a count. Standard error is for everything meant for a human: progress, warnings and explanations. A script that keeps this split can be used in a pipeline or a command substitution without any cleanup, and -q only has to control the second stream.

This split also tells you what -q should not do. It should not suppress the result, because a caller who asked for quiet usually wants only the result. A script that prints a path on stdout and chatter on stderr is useful in both modes, and the flag decides how much of the chatter appears. Mixing the two forces callers to filter your output with grep, which breaks the day a message is reworded.

Debug output and the cost of formatting

Debug messages tend to be expensive to build, such as dumping a large variable or running a command to describe state. Check the level before building the message, not inside the logging function, so a quiet run does not pay for text nobody will see. A guard like (( VERBOSITY >= 3 )) && log 3 "state: $(describe_state)" avoids the subshell entirely at the default level.

Keep the levels few. Four is plenty: quiet, normal, verbose and debug. More levels invite arguments about which message belongs where, and nobody remembers the difference between level four and level five at the moment they need it. Document the levels in the usage text next to the flags, so --help answers the question without anyone reading the source. Include the environment-variable form, such as VERBOSE=2, for places where editing the command line is awkward, like a service unit or a CI step that wraps your script.

Interaction with other flags and tools

Verbosity flags collide with existing conventions, so choose them with care. Many commands treat -q as quiet and -v as verbose, but some use -v for version or invert the meaning. If the script wraps another command, forward the level in a way that command understands, such as passing --quiet to curl or -q to tar, so one flag controls the whole run. Do not forward blindly: some tools print to stdout at their verbose level, which would pollute the result stream.

Child processes need a decision of their own. A quiet run should redirect the noisy output of tools like rsync or apt to a log file and print that file's tail only if the command fails. This keeps the terminal clean on success and still gives the person a reason when something breaks, without rerunning with a higher level. Write that log under a temporary directory made with mktemp -d and remove it with a trap on exit.

Document the interface once, in the usage text, and test it. A short test calling the script with each flag and checking that stdout and stderr contain what you expect takes minutes to write and prevents the slow drift where a refactor makes -q start printing again.

One more detail concerns progress output for long operations. A line that rewrites itself with a carriage return looks pleasant on a terminal and fills a log file with garbage. Emit such progress only when stderr is a terminal, and print plain, timestamped lines otherwise, so the same script is usable interactively and in a scheduled job without a separate mode.

Where this does not apply

Long options such as --verbose are not supported by the getopts builtin; handle them with a case loop on $1, or use the external getopt from util-linux, which is not on every system. For a three-line script, flags are overhead, and an environment variable like VERBOSE=1 ./script does the job. See the getopts entry in the Bash manual for the option-string syntax.

Taggedscripting
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.