Real World Patterns Idioms
This chapter doesn’t introduce much genuinely new syntax — it’s almost entirely about combining tools this course has already covered into the idioms professional Bash scripts actually use: leveled logging, lockfiles that prevent a script from running twice, retry logic for flaky operations, --dry-run safety nets, and consistent option handling. The one new practical danger — a lockfile that looks correct but can permanently block a script from ever running again — gets a full demonstration in this chapter’s safety section.
Logging Functions With Levels
A small, reusable logging function pays for itself almost immediately, and it’s a natural place to apply the local-then-separate-assignment discipline from Chapter 18:
log() {
local level="$1"
shift
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $*" >&2
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }log_info "Starting backup"
log_error "Could not reach remote host"[2026-08-23 14:02:11] [INFO] Starting backup
[2026-08-23 14:02:11] [ERROR] Could not reach remote hostNotice timestamp is declared with local on its own line, then assigned separately on the next — exactly the two-line pattern Chapter 18 established for avoiding set -e masking a command substitution’s failure, applied here as a matter of habit rather than because date is likely to fail. All log output goes to stderr — separation of diagnostic output from a script’s real output.
Lockfiles And PID Files — Preventing Double-Runs
A common problem for scheduled scripts (cron jobs, in particular) is a second run starting before the first one finishes. The standard fix is a lockfile recording the running process’s PID:
lock_file="/tmp/myscript.lock"
if [[ -f "$lock_file" ]]; then
existing_pid=$(cat "$lock_file")
if kill -0 "$existing_pid" 2>/dev/null; then
echo "Already running as PID $existing_pid" >&2
exit 1
fi
fi
echo "$$" > "$lock_file"
trap 'rm -f "$lock_file"' EXIT
echo "Doing work..."kill -0 doesn’t actually send a signal — it just checks whether a process with that PID exists and is reachable, making it the standard, safe way to test “is this process still running” without disturbing it. $$ records the current script’s own PID into the lockfile, and trap ... EXIT guarantees the lockfile is removed however the script ends. This naive-looking version has a real flaw, though — worked through fully in this chapter’s Shell-Safety Considerations section.
Retry With Backoff
Network calls, flaky external services, and anything else prone to transient failure benefit from automatic retries, ideally with an increasing delay between attempts rather than hammering the same failing operation repeatedly:
retry_with_backoff() {
local max_attempts="$1"
shift
local attempt=1
local delay=1
until "$@"; do
if (( attempt >= max_attempts )); then
log_error "Command failed after $attempt attempts: $*"
return 1
fi
log_warn "Attempt $attempt failed, retrying in ${delay}s..."
sleep "$delay"
(( attempt++ ))
(( delay *= 2 ))
done
}retry_with_backoff 5 curl -sf https://example.com/health"$@" here is forwarding the retried command and all its arguments intact, whatever they happen to be, including a plain function name if that’s what’s being retried. until keeps calling it until it finally succeeds or the attempt limit is reached, doubling delay each time via arithmetic expansion.
--dry-run Conventions
For any script that does something destructive or hard to undo, supporting a --dry-run (or short -n) flag that reports what would happen without actually doing it is a valuable safety net:
dry_run=false
run() {
if [[ "$dry_run" == true ]]; then
echo "[DRY RUN] Would run: $*"
else
"$@"
fi
}
run rm important-file.txt[DRY RUN] Would run: rm important-file.txtWrapping every potentially destructive action through a function like run means the dry-run check only has to be written once, rather than duplicated at every call site.
Configuration-File Conventions
Scripts intended for repeated or shared use often support an external config file, sourced at startup to override built-in defaults:
config_file="${MYSCRIPT_CONFIG:-/etc/myscript.conf}"
if [[ -f "$config_file" ]]; then
source "$config_file"
fi${MYSCRIPT_CONFIG:-...} lets the config path itself be overridden via an environment variable, falling back to a sensible default. Since source, runs a file’s contents directly in the current shell, a config file is not inert data — it’s executable code, sourced with exactly the same trust and access as the script itself. Treat config file locations and permissions accordingly: a config file writable by anyone other than the script’s own trusted owner is functionally equivalent to letting that other party run arbitrary code as your script.
Consistent --help/--version/Option Handling
Since getopts can’t parse long options like --help or --version directly, the common practical approach is a quick manual scan for those specific long forms before entering the getopts loop, alongside supporting their short equivalents through getopts normally:
show_usage() {
cat << 'EOF'
Usage: myscript [-h] [-v] [-f FILE]
-h, --help Show this help message and exit
-v, --version Show version information and exit
-f FILE Specify input file
EOF
}
for arg in "$@"; do
case "$arg" in
--help) show_usage; exit 0 ;;
--version) echo "myscript 1.0.0"; exit 0 ;;
esac
done
while getopts ":hvf:" opt; do
case "$opt" in
h) show_usage; exit 0 ;;
v) echo "myscript 1.0.0"; exit 0 ;;
f) filename="$OPTARG" ;;
\?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
:) echo "Option -$OPTARG requires an argument" >&2; exit 1 ;;
esac
doneThis hybrid — a manual pre-scan for the handful of long flags that matter most, getopts for everything short-form — is a practical, common resolution to getopts’s stated limitation, without needing to abandon getopts entirely just to support --help.
Safe Command Construction With Arrays
Every dynamic command built in this chapter — retry_with_backoff’s "$@", run’s "$@" — relies on the array-safety discipline: build commands and their arguments as arrays or forwarded positional parameters, never as a concatenated string, so that spaces and special characters inside any individual argument survive intact all the way through.
Best Practices
- Log to stderr with levels, using a small shared function, rather than scattering bare
echocalls throughout a script. - Use a lockfile with a liveness check (
kill -0), not just an existence check, for any script prone to overlapping runs — the existence-only version is covered failing in full below. - Wrap retryable operations in a backoff function rather than hand-rolling a retry loop at every call site that needs one.
- Support
--dry-runfor anything destructive, and route every destructive action through a single wrapper function that checks it. - Treat sourced config files as executable code, and control their permissions and location accordingly.
Shell-Safety Considerations
The lockfile pattern shown earlier in this chapter looks complete, but it has a real, common failure mode: a script that dies without reaching its trap-based cleanup — most notably, one killed with SIGKILL, which Chapter 19 established can never be trapped — leaves its lockfile behind permanently, blocking every future run indefinitely.
Naive (broken) version — checks only whether the lockfile exists, not whether the process it names is actually still running:
lock_file="/tmp/shell-course-demo.lock"
# Simulate a stale lock left behind by a script that was killed with SIGKILL
echo "99999" > "$lock_file"
if [[ -f "$lock_file" ]]; then
echo "Already running, exiting" >&2
exit 1
fi
echo "$$" > "$lock_file"
echo "Doing work..."Already running, exitingPID 99999 almost certainly doesn’t correspond to any real running process — it’s standing in for a genuinely crashed prior run — but this version has no way to tell the difference between “still running” and “leftover file from a run that never cleaned up.” The script now refuses to run forever, until someone manually notices and deletes the stale lockfile by hand.
Corrected version — check whether the recorded PID is actually alive with kill -0, and treat a dead PID’s lockfile as stale rather than as an active lock:
lock_file="/tmp/shell-course-demo.lock"
echo "99999" > "$lock_file"
if [[ -f "$lock_file" ]]; then
existing_pid=$(cat "$lock_file")
if kill -0 "$existing_pid" 2>/dev/null; then
echo "Already running as PID $existing_pid" >&2
exit 1
else
echo "Found stale lock (PID $existing_pid no longer running) — removing" >&2
rm -f "$lock_file"
fi
fi
echo "$$" > "$lock_file"
trap 'rm -f "$lock_file"' EXIT
echo "Doing work..."Found stale lock (PID 99999 no longer running) — removing
Doing work...This version correctly distinguishes a genuinely active run from a stale leftover file, recovering automatically instead of requiring manual intervention. This is the version worth using anywhere a lockfile matters — the existence-only check is a trap that works fine in every quick test and then locks a script out of ever running again the first time it’s actually killed uncleanly in production.
Hands-On: Building The Patterns
1. Set up a working directory.
mkdir -p ~/shell-course/ch22
cd ~/shell-course/ch222. Build and use the leveled logging functions.
cat > logging.sh << 'EOF'
log() {
local level="$1"
shift
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $*" >&2
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
EOF
source logging.sh
log_info "Starting demo"
log_warn "This is a warning"
log_error "This is an error"3. Build retry_with_backoff and test it against a command that fails a few times before succeeding.
source logging.sh
counter_file=$(mktemp)
echo 0 > "$counter_file"
flaky_command() {
local count
count=$(cat "$counter_file")
count=$((count + 1))
echo "$count" > "$counter_file"
if (( count < 3 )); then
echo "Attempt $count: simulated failure" >&2
return 1
fi
echo "Attempt $count: success"
}
retry_with_backoff() {
local max_attempts="$1"
shift
local attempt=1
local delay=1
until "$@"; do
if (( attempt >= max_attempts )); then
log_error "Command failed after $attempt attempts: $*"
return 1
fi
log_warn "Attempt $attempt failed, retrying in ${delay}s..."
sleep "$delay"
(( attempt++ ))
(( delay *= 2 ))
done
}
retry_with_backoff 5 flaky_command
rm -f "$counter_file"4. Build the --dry-run wrapper and test it.
dry_run=true
run() {
if [[ "$dry_run" == true ]]; then
echo "[DRY RUN] Would run: $*"
else
"$@"
fi
}
touch sample.txt
run rm sample.txt
ls sample.txt5. Reproduce the stale-lockfile bug, then fix it.
echo "--- broken ---"
lock_file="/tmp/shell-course-demo.lock"
echo "99999" > "$lock_file"
if [[ -f "$lock_file" ]]; then
echo "Already running, exiting" >&2
fi
echo "--- fixed ---"
rm -f "$lock_file"
echo "99999" > "$lock_file"
if [[ -f "$lock_file" ]]; then
existing_pid=$(cat "$lock_file")
if kill -0 "$existing_pid" 2>/dev/null; then
echo "Already running as PID $existing_pid" >&2
else
echo "Found stale lock (PID $existing_pid no longer running) — removing"
rm -f "$lock_file"
fi
fi
echo "$$" > "$lock_file"
trap 'rm -f "$lock_file"' EXIT
echo "Doing work..."6. Clean up.
cd ~
rm -rf ~/shell-course/ch22
rm -f /tmp/shell-course-demo.lockYou’ve now built a reusable logging function, a retry-with-backoff wrapper, a --dry-run safety net, and a lockfile pattern — and specifically confirmed why an existence-only lockfile check is a trap that fails permanently the first time a script is killed uncleanly in production, versus the kill -0-based version that recovers automatically. That completes this section on safety, debugging, and real-world patterns. The final section of this course moves into projects — evolving a real backup script from a naive first pass through to a production-ready tool, then a second, faster-moving project applying the same techniques to a health-check and alerting script.