Skip to main content
Bash · lesson 20 of 22
Bash intermediate 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

  1. Fix dryrun-bug.sh so --dry-run is 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
  2. Rewrite progress_bar so it is exactly width characters 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%
  3. Write ensure_symlink TARGET LINK so 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/v2

    ls releases/v1 printed nothing, so no stray link was created inside the old release. With ln -sf instead of -sfn, the third call would have put a v2 link inside releases/v1 (see the file-operations lesson).

Frequently Asked Questions

When should I use getopts vs manual argument parsing?
getopts handles short options (-v, -o file) reliably and is POSIX standard. For long options (--verbose, --output=file), parse manually or use getopt (external). For simple scripts, a manual while/case loop over $@ is often clearest.
How do I make my script idempotent?
Design each action to be safe to run multiple times. Check state before acting: create a dir only if it doesn't exist, insert a DB row only if the key is absent. Idempotent scripts can be safely re-run after failures.
What is a lockfile and why do I need it?
A lockfile prevents two instances of the same script from running simultaneously, which is critical for cron jobs and daemons. Use flock for atomic locking — a manual touch/check approach has a race condition.
Does getopts accept options after positional arguments?
No. getopts stops at the first argument that is not an option, so in 'script.sh file.csv -v' the -v is left as a positional argument and never parsed. Put options first, or use a manual while/case loop if you need options anywhere on the command line.