Bash scripts have a reputation for being fragile, unreadable hacks. They don’t have to be. These patterns will help you write scripts that are maintainable, debuggable, and safe to run in production.
The Essentials: Start Every Script Right#
1
2
3
4
| #!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
|
What these do:
set -e — Exit on any errorset -u — Error on undefined variablesset -o pipefail — Catch errors in pipelinesIFS=$'\n\t' — Safer word splitting (no spaces)
Script Template#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
| #!/usr/bin/env bash
set -euo pipefail
# Script metadata
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly SCRIPT_VERSION="1.0.0"
# Default values
VERBOSE=false
DRY_RUN=false
CONFIG_FILE=""
# Colors (if terminal supports them)
if [[ -t 1 ]]; then
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[0;33m'
readonly NC='\033[0m' # No Color
else
readonly RED='' GREEN='' YELLOW='' NC=''
fi
# Logging functions
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
log_debug() { [[ "$VERBOSE" == true ]] && echo -e "[DEBUG] $*" || true; }
# Cleanup function
cleanup() {
local exit_code=$?
# Add cleanup tasks here
log_debug "Cleaning up..."
exit $exit_code
}
trap cleanup EXIT
# Usage
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS] <argument>
Description of what this script does.
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
-n, --dry-run Show what would be done
-c, --config FILE Path to config file
--version Show version
Examples:
$SCRIPT_NAME -v input.txt
$SCRIPT_NAME --config /etc/myapp.conf data/
EOF
}
# Parse arguments
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
usage
exit 0
;;
-v|--verbose)
VERBOSE=true
shift
;;
-n|--dry-run)
DRY_RUN=true
shift
;;
-c|--config)
CONFIG_FILE="$2"
shift 2
;;
--version)
echo "$SCRIPT_NAME version $SCRIPT_VERSION"
exit 0
;;
-*)
log_error "Unknown option: $1"
usage
exit 1
;;
*)
ARGS+=("$1")
shift
;;
esac
done
}
# Validation
validate() {
if [[ ${#ARGS[@]} -eq 0 ]]; then
log_error "Missing required argument"
usage
exit 1
fi
if [[ -n "$CONFIG_FILE" && ! -f "$CONFIG_FILE" ]]; then
log_error "Config file not found: $CONFIG_FILE"
exit 1
fi
}
# Main logic
main() {
log_info "Starting $SCRIPT_NAME"
log_debug "Arguments: ${ARGS[*]}"
for arg in "${ARGS[@]}"; do
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY RUN] Would process: $arg"
else
log_info "Processing: $arg"
# Actual work here
fi
done
log_info "Done"
}
# Entry point
ARGS=()
parse_args "$@"
validate
main
|
Error Handling#
Check Command Existence#
1
2
3
4
5
6
7
8
9
10
| require_command() {
if ! command -v "$1" &> /dev/null; then
log_error "Required command not found: $1"
exit 1
fi
}
require_command curl
require_command jq
require_command docker
|
Retry Pattern#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| retry() {
local max_attempts=$1
local delay=$2
shift 2
local cmd=("$@")
local attempt=1
while (( attempt <= max_attempts )); do
if "${cmd[@]}"; then
return 0
fi
log_warn "Attempt $attempt/$max_attempts failed, retrying in ${delay}s..."
sleep "$delay"
((attempt++))
done
log_error "Command failed after $max_attempts attempts: ${cmd[*]}"
return 1
}
# Usage
retry 3 5 curl -sf https://api.example.com/health
|
Timeout Pattern#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| with_timeout() {
local timeout=$1
shift
timeout "$timeout" "$@" || {
local exit_code=$?
if [[ $exit_code -eq 124 ]]; then
log_error "Command timed out after ${timeout}s"
fi
return $exit_code
}
}
# Usage
with_timeout 30 curl -s https://api.example.com
|
Safe File Operations#
Temporary Files#
1
2
3
4
5
6
7
| # Create temp file that's automatically cleaned up
TEMP_FILE=$(mktemp)
trap 'rm -f "$TEMP_FILE"' EXIT
# Or temp directory
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT
|
Safe Write (Atomic)#
1
2
3
4
5
6
7
8
9
10
11
12
| safe_write() {
local target=$1
local content=$2
local temp_file
temp_file=$(mktemp)
echo "$content" > "$temp_file"
mv "$temp_file" "$target"
}
# Usage
safe_write /etc/myapp/config.json '{"key": "value"}'
|
Backup Before Modify#
1
2
3
4
5
6
7
8
9
| backup_file() {
local file=$1
local backup="${file}.bak.$(date +%Y%m%d_%H%M%S)"
if [[ -f "$file" ]]; then
cp "$file" "$backup"
log_info "Backup created: $backup"
fi
}
|
File Checks#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| validate_file() {
local file=$1
[[ -e "$file" ]] || { log_error "File not found: $file"; return 1; }
[[ -f "$file" ]] || { log_error "Not a regular file: $file"; return 1; }
[[ -r "$file" ]] || { log_error "File not readable: $file"; return 1; }
}
validate_directory() {
local dir=$1
[[ -e "$dir" ]] || { log_error "Directory not found: $dir"; return 1; }
[[ -d "$dir" ]] || { log_error "Not a directory: $dir"; return 1; }
[[ -w "$dir" ]] || { log_error "Directory not writable: $dir"; return 1; }
}
|
1
2
3
4
5
6
7
8
| sanitize_filename() {
local input=$1
# Remove path components and dangerous characters
echo "${input##*/}" | tr -cd '[:alnum:]._-'
}
# Usage
safe_name=$(sanitize_filename "$user_input")
|
Configuration#
Config File Loading#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| load_config() {
local config_file=$1
if [[ -f "$config_file" ]]; then
log_debug "Loading config: $config_file"
# shellcheck source=/dev/null
source "$config_file"
else
log_warn "Config file not found, using defaults: $config_file"
fi
}
# Config file format (config.sh):
# DB_HOST="localhost"
# DB_PORT=5432
# ENABLE_FEATURE=true
|
Environment Variable Defaults#
1
2
3
4
5
6
| : "${DB_HOST:=localhost}"
: "${DB_PORT:=5432}"
: "${LOG_LEVEL:=info}"
# Or with validation
DB_HOST="${DB_HOST:?DB_HOST environment variable required}"
|
Parallel Execution#
Using xargs#
1
2
3
4
| process_files() {
find . -name "*.txt" -print0 | \
xargs -0 -P 4 -I {} bash -c 'process_single_file "$1"' _ {}
}
|
Using Background Jobs#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| parallel_tasks() {
local pids=()
for item in "${items[@]}"; do
process_item "$item" &
pids+=($!)
done
# Wait for all and check results
local failed=0
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
((failed++))
fi
done
[[ $failed -eq 0 ]] || log_error "$failed tasks failed"
return $failed
}
|
Locking#
Prevent Concurrent Runs#
1
2
3
4
5
6
7
8
9
10
11
| LOCK_FILE="/var/run/${SCRIPT_NAME}.lock"
acquire_lock() {
if ! mkdir "$LOCK_FILE" 2>/dev/null; then
log_error "Another instance is running (lock: $LOCK_FILE)"
exit 1
fi
trap 'rm -rf "$LOCK_FILE"' EXIT
}
acquire_lock
|
With flock#
1
2
3
4
5
6
7
8
9
10
11
12
| LOCK_FD=200
LOCK_FILE="/var/run/${SCRIPT_NAME}.lock"
acquire_lock() {
eval "exec $LOCK_FD>$LOCK_FILE"
if ! flock -n $LOCK_FD; then
log_error "Another instance is running"
exit 1
fi
}
acquire_lock
|
Testing#
Assertions#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| assert_equals() {
local expected=$1 actual=$2 message=${3:-"Assertion failed"}
if [[ "$expected" != "$actual" ]]; then
log_error "$message: expected '$expected', got '$actual'"
return 1
fi
}
assert_file_exists() {
local file=$1
if [[ ! -f "$file" ]]; then
log_error "File should exist: $file"
return 1
fi
}
# Usage
assert_equals "200" "$status_code" "HTTP status check"
assert_file_exists "/tmp/output.txt"
|
Test Mode#
1
2
3
4
5
6
7
| if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
# Script is being run directly
main "$@"
else
# Script is being sourced (for testing)
log_debug "Script sourced, not executing main"
fi
|
Debugging#
Debug Mode#
1
| [[ "${DEBUG:-}" == true ]] && set -x
|
Trace Function Calls#
1
2
3
4
5
6
7
8
| trace() {
log_debug "TRACE: ${FUNCNAME[1]}(${*})"
}
my_function() {
trace "$@"
# function body
}
|
Common Pitfalls#
Quote Everything#
1
2
3
4
5
| # Bad
if [ $var == "value" ]; then
# Good
if [[ "$var" == "value" ]]; then
|
Use Arrays for Lists#
1
2
3
4
5
6
7
| # Bad
files="file1.txt file2.txt file with spaces.txt"
for f in $files; do echo "$f"; done
# Good
files=("file1.txt" "file2.txt" "file with spaces.txt")
for f in "${files[@]}"; do echo "$f"; done
|
Check Command Success#
1
2
3
4
5
6
7
8
9
10
11
| # Bad
output=$(some_command)
echo "$output"
# Good
if output=$(some_command 2>&1); then
echo "$output"
else
log_error "Command failed: $output"
exit 1
fi
|
Good bash scripts are defensive, verbose when needed, and fail fast. Start with the template, add what you need, and resist the urge to be clever.
When a script grows past a few hundred lines, consider whether it should be Python instead. Bash is for glue, not application logic.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.