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 error
  • set -u — Error on undefined variables
  • set -o pipefail — Catch errors in pipelines
  • IFS=$'\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
}

Input Validation

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; }
}

Input Sanitization

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.