Bash · lesson 20 of 22
Bash Script Patterns
Learn production-ready patterns — argument parsing with getopts, usage functions, loading config files, and script locking.
By SkillByExample · Updated
Every example below is a complete script, run with bash 5.2 and util-linux flock on
Ubuntu 24.04. Several of them are run more than once in the same command, to show the
behaviour on different inputs.
A Script Skeleton
Strict mode, named constants, log helpers, an EXIT trap, and a main called on the
last line:
set -euo pipefail
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
readonly VERSION="1.0.0"
VERBOSE=false
log() { printf '[INFO] %s\n' "$*"; }
debug() { $VERBOSE && printf '[DEBUG] %s\n' "$*" >&2 || true; }
die() { printf '[ERROR] %s\n' "$1" >&2; exit "${2:-1}"; }
cleanup() { local rc=$?; printf '[EXIT] cleanup ran, status %s\n' "$rc" >&2; }
trap cleanup EXIT
main() {
[[ ${1:-} == -v ]] && { VERBOSE=true; shift; }
[[ $# -ge 1 ]] || die "usage: $SCRIPT_NAME [-v] <name>" 2
log "Starting $SCRIPT_NAME v$VERSION for $1"
debug "verbose mode on"
log "Done"
}
main "$@"
$ bash skeleton.sh app; bash skeleton.sh -v app; bash skeleton.sh; echo "exit $?"
[INFO] Starting skeleton.sh v1.0.0 for app
[INFO] Done
[EXIT] cleanup ran, status 0
[INFO] Starting skeleton.sh v1.0.0 for app
[DEBUG] verbose mode on
[INFO] Done
[EXIT] cleanup ran, status 0
[ERROR] usage: skeleton.sh [-v] <name>
[EXIT] cleanup ran, status 2
exit 2
The || true in debug matters under set -e. Without it, a debug call in normal mode
returns the status of the failed $VERBOSE test and ends the script. The [EXIT] lines show the
cleanup trap running on every exit, including after die, with the real exit status.
Short Options with getopts
getopts handles bundled flags (-vn), attached values (-oout.txt) and error reporting.
output="" verbose=false dry_run=false
die() { echo "ERROR: $1" >&2; exit 2; }
while getopts ":o:vn" opt; do
case "$opt" in
o) output="$OPTARG" ;;
v) verbose=true ;;
n) dry_run=true ;;
:) die "option -$OPTARG requires an argument" ;;
\?) die "unknown option: -$OPTARG" ;;
esac
done
shift $(( OPTIND - 1 ))
echo "output=${output:-none} verbose=$verbose dry_run=$dry_run args=[$*]"
$ bash getopts.sh -vn -o out.txt a.csv b.csv; bash getopts.sh -oout.txt a.csv; bash getopts.sh -x; bash getopts.sh -o; bash getopts.sh a.csv -v
output=out.txt verbose=true dry_run=true args=[a.csv b.csv]
output=out.txt verbose=false dry_run=false args=[a.csv]
ERROR: unknown option: -x
ERROR: option -o requires an argument
output=none verbose=false dry_run=false args=[a.csv -v]
The leading : in ":o:vn" switches getopts to silent mode, so it reports problems through
: and ? and your own messages are used. Escape \? in the case: an unescaped ? is a
glob that matches any single-character option. The last run shows getopts’ main limit:
it stops at the first non-option argument, so -v after a.csv was never parsed.
Long Options by Hand
A while/case loop handles --name value, --name=value and the -- marker that ends
option parsing:
output="" verbose=false
while [[ $# -gt 0 ]]; do
case "$1" in
-o|--output) [[ $# -gt 1 ]] || { echo "ERROR: $1 needs a value" >&2; exit 2; }
output="$2"; shift 2 ;;
--output=*) output="${1#--output=}"; shift ;;
-v|--verbose) verbose=true; shift ;;
--) shift; break ;;
-*) echo "ERROR: unknown option $1" >&2; exit 2 ;;
*) break ;;
esac
done
echo "output=${output:-none} verbose=$verbose args=[$*]"
$ bash longopts.sh --verbose --output=r.txt in.csv; bash longopts.sh -o r.txt -- -weird-name.csv
output=r.txt verbose=true args=[in.csv]
output=r.txt verbose=false args=[-weird-name.csv]
Without the --, the file named -weird-name.csv would have been rejected as an unknown
option.
Layered Configuration
Each layer overrides the one before it: built-in defaults, then a system file, then a user file, then environment variables.
mkdir -p etc
echo 'DB_HOST=db.internal' > etc/system.conf
echo 'DB_PORT=6543' > user.conf
load_config() {
[[ -f $1 ]] || { echo "skip: $1 (not found)"; return 0; }
source "$1"
echo "loaded: $1"
}
DB_HOST="localhost"
DB_PORT="5432"
load_config etc/system.conf
load_config user.conf
load_config missing.conf
DB_HOST="${MYAPP_DB_HOST:-$DB_HOST}"
DB_PORT="${MYAPP_DB_PORT:-$DB_PORT}"
echo "final: $DB_HOST:$DB_PORT"
$ bash config.sh; MYAPP_DB_HOST=override bash config.sh | tail -1
loaded: etc/system.conf
loaded: user.conf
skip: missing.conf (not found)
final: db.internal:6543
final: override:6543
source runs the config file as Bash code. Anyone who can write to that file can run
commands as the user running your script. Only source files owned by that user or by root,
or parse KEY=VALUE lines yourself (see the regex and security lessons).
Locking with flock
flock takes an atomic lock, so two copies of a cron job can’t run at the same time.
exec 9> job.lock
if ! flock -n 9; then
echo "[$1] another instance is running; exiting"
exit 0
fi
echo "[$1] got the lock, working 2s"
sleep 2
echo "[$1] done"
$ bash locked.sh first & sleep 0.5; bash locked.sh second; wait; bash locked.sh third
[first] got the lock, working 2s
[second] another instance is running; exiting
[first] done
[third] got the lock, working 2s
[third] done
The kernel releases the lock when descriptor 9 is closed, which happens when the script
exits for any reason, including a crash. That makes it safer than a hand-rolled lock file,
which is left behind if the script dies. flock -w 30 9 waits up to 30 seconds instead of
giving up at once. In a crontab, flock -n /tmp/job.lock /path/job.sh locks a script
without changing it.
Idempotent Steps
An idempotent script can run again after a failure without doubling anything up. Check the state first, then change only what’s needed:
log() { echo " $*"; }
ensure_line() {
grep -qxF "$1" "$2" 2>/dev/null && { log "already present: $1"; return 0; }
echo "$1" >> "$2"
log "added: $1"
}
ensure_dir() {
[[ -d $1 ]] && { log "exists: $1"; return 0; }
mkdir -p "$1" && log "created: $1"
}
for run in 1 2; do
echo "run $run:"
ensure_dir data/cache
ensure_line 'export PATH=/opt/tool/bin:$PATH' profile.sh
done
echo "profile.sh has $(wc -l < profile.sh) line(s)"
$ bash idempotent.sh
run 1:
created: data/cache
added: export PATH=/opt/tool/bin:$PATH
run 2:
exists: data/cache
already present: export PATH=/opt/tool/bin:$PATH
profile.sh has 1 line(s)
grep -qxF matches the whole line (-x) as a fixed string (-F). Without -F, the $
and . characters in a PATH line would be read as regex syntax.
Dry-Run Mode: Order Matters
A run wrapper that only prints commands in dry-run mode is a good safety feature, but it
only helps if the flag is read before anything runs:
DRY_RUN=false
run() { if $DRY_RUN; then echo "[DRY RUN] $*"; else "$@"; fi; }
mkdir -p cache && touch cache/data.bin
run rm -rf cache
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true
[[ -d cache ]] && echo "cache still here" || echo "cache was DELETED"
$ bash dryrun-bug.sh --dry-run
cache was DELETED
The operator asked for a dry run and lost the directory anyway. The flag was checked after the destructive command had already run. Parse every option before the first action. Practice task 1 shows the fixed script.
Progress Bars
Here is a common one-liner progress bar. Its edge cases give it away:
progress_bar() {
local current="$1" total="$2" width=10
local filled=$(( current * width / total ))
local empty=$(( width - filled ))
printf '[%s%s] %3d%%\n' \
"$(printf '#%.0s' $(seq 1 $filled))" \
"$(printf ' %.0s' $(seq 1 $empty))" \
"$(( current * 100 / total ))"
}
progress_bar 0 10
progress_bar 5 10
progress_bar 10 10
$ bash progress.sh
[# ] 0%
[##### ] 50%
[########## ] 100%
At 0% the bar shows one #, and at 100% it is one character too wide. seq 1 0 prints
nothing, and printf with a format but no arguments still prints the format once. That
extra # or space appears whenever a count is zero. Practice task 2 builds the bar without
seq.
Practice
-
Fix
dryrun-bug.shso--dry-runis respected, and show that a normal run still deletes.Solution
DRY_RUN=false [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true run() { if $DRY_RUN; then echo "[DRY RUN] $*"; else "$@"; fi; } mkdir -p cache && touch cache/data.bin run rm -rf cache [[ -d cache ]] && echo "cache still here" || echo "cache was DELETED"$ bash p1.sh --dry-run; bash p1.sh [DRY RUN] rm -rf cache cache still here cache was DELETED -
Rewrite
progress_barso it is exactlywidthcharacters wide at 0%, 50% and 100%.Solution
printf -v var '%*s' N ''makes N spaces, and zero is handled correctly:progress_bar() { local current="$1" total="$2" width=10 bar gap local filled=$(( current * width / total )) printf -v bar '%*s' "$filled" '' printf -v gap '%*s' $(( width - filled )) '' printf '[%s%s] %3d%%\n' "${bar// /#}" "$gap" "$(( current * 100 / total ))" } progress_bar 0 10 progress_bar 5 10 progress_bar 10 10$ bash p2.sh [ ] 0% [##### ] 50% [##########] 100% -
Write
ensure_symlink TARGET LINKso that it does nothing when the link is already correct and repoints it when it isn’t. It must work for directory targets.Solution
ensure_symlink() { if [[ -L $2 && $(readlink "$2") == "$1" ]]; then echo "already correct: $2 -> $1" return 0 fi ln -sfn "$1" "$2" echo "linked: $2 -> $1" } mkdir -p releases/v1 releases/v2 ensure_symlink releases/v1 current ensure_symlink releases/v1 current ensure_symlink releases/v2 current ls releases/v1$ bash p3.sh linked: current -> releases/v1 already correct: current -> releases/v1 linked: current -> releases/v2ls releases/v1printed nothing, so no stray link was created inside the old release. Withln -sfinstead of-sfn, the third call would have put av2link insidereleases/v1(see the file-operations lesson).