LinuxBash Scripting

Bash Error Handling, Traps, and Defensive Scripting

Write fault-tolerant Bash scripts with set -euo pipefail, trap for cleanup, custom error handlers, and retry logic. Lesson 6 of the Linux & Bash Scripting course.

TT
Daniel Brooks
5 min read
Bash Error Handling, Traps, and Defensive Scripting

The difference between a script that works in testing and one that survives production is error handling. Without it, a failed command silently passes control to the next line; a pipeline error is swallowed; a script interrupted mid-run leaves behind partial state. This lesson covers the complete error-handling toolkit: set options, trap, custom error handlers, and patterns for graceful recovery.

Previous: Lesson 5 — Functions, Control Flow, and Exit Codes


set Options — The Foundation

Add these three options to every production script, immediately after the shebang:

bash
#!/usr/bin/env bash
set -euo pipefail
OptionEffect
-e (errexit)Exit immediately if any command returns a non-zero exit code
-u (nounset)Treat unset variables as errors — prevents silent bugs from empty $1 etc.
-o pipefailA pipeline fails if any command in it fails, not just the last one
bash
# Without pipefail — this succeeds even though grep exits 1 (no match)
grep "MISSING" /var/log/app.log | wc -l   # prints 0, exit code 0

# With pipefail — grep's exit code 1 propagates
set -o pipefail
grep "MISSING" /var/log/app.log | wc -l   # exits with 1

Temporary Option Overrides

Sometimes you need to run a command that is allowed to fail:

bash
set -euo pipefail

# Disable -e for one command, then re-enable
set +e
grep "pattern" file.txt
grep_exit=$?
set -e

# Cleaner alternative: use || true to absorb a non-zero exit
grep "pattern" file.txt || true

# Conditional without triggering -e
if grep -q "pattern" file.txt; then
    echo "Found"
fi

The trap Command

trap registers a command to run when the shell receives a signal or reaches a specific state.

bash
trap 'command' SIGNAL [SIGNAL...]

Cleanup on EXIT

The EXIT pseudo-signal fires whenever the script exits — whether normally, via exit, or due to an error. It is the correct place for cleanup logic:

bash
#!/usr/bin/env bash
set -euo pipefail

readonly TMP_DIR="$(mktemp -d)"

cleanup() {
    local exit_code=$?
    rm -rf "$TMP_DIR"
    if [[ $exit_code -ne 0 ]]; then
        echo "Script failed with exit code: $exit_code" >&2
    fi
    exit "$exit_code"
}

trap cleanup EXIT

# Script body — TMP_DIR is always cleaned up
cp /large/file.tar.gz "$TMP_DIR/"
tar -xzf "${TMP_DIR}/file.tar.gz" -C "$TMP_DIR"
process_extracted_files "$TMP_DIR"

ERR Trap — Custom Error Handler

The ERR trap fires whenever a command fails (when -e is set). Use it to add context to failures:

bash
#!/usr/bin/env bash
set -euo pipefail

on_error() {
    local exit_code=$?
    local line_number="${BASH_LINENO[0]}"
    local command="${BASH_COMMAND}"
    echo "ERROR: Command '${command}' failed with exit code ${exit_code} on line ${line_number}" >&2
}

trap on_error ERR

Signal Handling — SIGINT and SIGTERM

Handle interrupt and termination signals to allow graceful shutdown:

bash
#!/usr/bin/env bash
set -euo pipefail

SHUTDOWN=false

handle_signal() {
    echo "Received shutdown signal — finishing current task..." >&2
    SHUTDOWN=true
}

trap handle_signal SIGINT SIGTERM

# Long-running loop that respects shutdown requests
while true; do
    if [[ "$SHUTDOWN" == "true" ]]; then
        echo "Shutting down gracefully"
        exit 0
    fi
    process_next_job
    sleep 1
done

Combining EXIT and ERR Traps

bash
#!/usr/bin/env bash
set -euo pipefail

readonly LOCK_FILE="/var/run/myapp.lock"
readonly TMP_DIR="$(mktemp -d)"

cleanup() {
    rm -f "$LOCK_FILE"
    rm -rf "$TMP_DIR"
}

error_handler() {
    echo "Failed at line ${BASH_LINENO[0]}: ${BASH_COMMAND}" >&2
    # Slack/webhook alert could go here
}

trap cleanup EXIT
trap error_handler ERR

# Prevent concurrent runs
if [[ -f "$LOCK_FILE" ]]; then
    echo "Another instance is running (lock: $LOCK_FILE)" >&2
    exit 1
fi
touch "$LOCK_FILE"

Defensive Patterns

Validate All Inputs

bash
#!/usr/bin/env bash
set -euo pipefail

validate_inputs() {
    local env="$1"
    local version="$2"

    local valid_envs=("production" "staging" "development")
    local valid=false
    for e in "${valid_envs[@]}"; do
        [[ "$env" == "$e" ]] && valid=true && break
    done

    if [[ "$valid" == "false" ]]; then
        echo "ERROR: Invalid environment '${env}'. Valid: ${valid_envs[*]}" >&2
        exit 1
    fi

    if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
        echo "ERROR: Version '${version}' is not in semver format (e.g. 1.2.3)" >&2
        exit 1
    fi
}

Require External Commands

Fail fast if a dependency is missing rather than letting the script fail mid-execution:

bash
require_commands() {
    local missing=()
    for cmd in "$@"; do
        if ! command -v "$cmd" &>/dev/null; then
            missing+=("$cmd")
        fi
    done
    if (( ${#missing[@]} > 0 )); then
        echo "ERROR: Required commands not found: ${missing[*]}" >&2
        exit 1
    fi
}

require_commands curl jq git docker kubectl

Retry Logic with Backoff

bash
retry() {
    local max_attempts="$1"
    local delay="$2"
    shift 2
    local command=("$@")
    local attempt=0

    until "${command[@]}"; do
        (( attempt++ ))
        if (( attempt >= max_attempts )); then
            echo "ERROR: Command failed after ${max_attempts} attempts: ${command[*]}" >&2
            return 1
        fi
        echo "Attempt ${attempt}/${max_attempts} failed. Retrying in ${delay}s..."
        sleep "$delay"
        (( delay = delay * 2 ))
    done
}

# Usage
retry 5 2 curl -sf http://api.example.com/health
retry 3 10 kubectl rollout status deployment/myapp

Atomic File Writes

Writing directly to a target file leaves it in a corrupt state if the script is interrupted mid-write. Write to a temp file and rename atomically:

bash
write_config() {
    local target="$1"
    local tmp
    tmp="$(mktemp "${target}.XXXXXX")"

    # Generate content to temp file
    generate_config > "$tmp"

    # Validate before deploying
    if ! validate_config "$tmp"; then
        rm -f "$tmp"
        echo "ERROR: Generated config failed validation" >&2
        return 1
    fi

    # Atomic rename — either the whole file is written or nothing
    mv "$tmp" "$target"
    echo "Config written to $target"
}

Locking — Preventing Concurrent Execution

bash
#!/usr/bin/env bash
set -euo pipefail

acquire_lock() {
    local lock_file="$1"
    local lock_fd=200

    exec {lock_fd}>"$lock_file"

    if ! flock -n "$lock_fd"; then
        echo "ERROR: Could not acquire lock on ${lock_file}. Is another instance running?" >&2
        exit 1
    fi
}

release_lock() {
    flock -u 200
}

trap release_lock EXIT

acquire_lock "/var/run/backup.lock"
# ... rest of script runs exclusively ...