Shell Scripting Error Handling & Debugging: Complete Guide
Scripts fail. It's not a matter of if, but when. A file goes missing, a network call times out, a variable is unset, or a command in a pipeline silently fails. Without proper error handling, these failures cascade into data corruption, partial deployments, or hours of troubleshooting.
In this guide, we'll build the mindset and toolkit for writing resilient shell scripts. You'll learn how to interpret exit codes, enforce strict execution, handle failures gracefully, debug effectively, and ensure cleanup happens no matter what.
Understanding Exit Codes
Every command in Linux returns an exit code (also called exit status) — an integer between 0 and 255 — when it finishes. By convention:
| Exit Code | Meaning |
|---|---|
0 | Success |
1 | General error |
2 | Misuse of shell builtins |
126 | Command found but not executable |
127 | Command not found |
128+N | Fatal error signal N |
130 | Script terminated by Ctrl+C (128 + 2) |
137 | Killed with SIGKILL (128 + 9) |
255 | Exit status out of range |
Checking the Exit Code
The special variable $? holds the exit code of the last executed command:
1#!/bin/bash 2 3ls /nonexistent 4echo "Exit code: $?" # Output: Exit code: 2
Using Exit Codes in Logic
1#!/bin/bash 2 3mkdir /opt/myapp 4 5if [ $? -eq 0 ]; then 6 echo "Directory created successfully." 7else 8 echo "Failed to create directory." >&2 9 exit 1 10fi
Better Practice: Instead of checking
$?explicitly, useif command; then— it's cleaner and avoids race conditions if another command runs between the check and the test.
Strict Mode: set -euo pipefail
The single most impactful change you can make to your scripts is enabling strict mode at the top. This catches three classes of common bugs automatically.
set -e — Exit on Error
1#!/bin/bash 2set -e 3 4command1 5command2 # If this fails, the script exits immediately 6command3
Without set -e, a failed command prints an error but the script continues running — often with catastrophic results. With set -e, the script aborts at the first failure.
Caveat:
set -ehas some edge cases (it doesn't trigger in certain compound commands). Use it as a safety net, not your only line of defense.
set -u — Treat Unset Variables as Errors
1#!/bin/bash 2set -u 3 4echo "$UNSET_VAR" # Script exits with error instead of printing empty string
Without set -u, referencing an unset variable silently expands to an empty string. This can cause rm -rf /$undefined_dir to become rm -rf / — a disaster.
set -o pipefail — Catch Failures in Pipelines
1#!/bin/bash 2set -o pipefail 3 4cat file.txt | grep "pattern" | wc -l
Without pipefail, the exit status of a pipeline is the exit status of the last command only. If cat file.txt fails but wc -l succeeds, the overall exit code is 0 — success. With pipefail, the pipeline fails if any command fails.
The Golden Combination
1#!/bin/bash 2set -euo pipefail
This one line should appear at the top of virtually every production script you write. It enforces:
-e: Stop on first error-u: No silent empty variables-o pipefail: No hidden pipeline failures
Pro Tip: If you need to allow a command to fail without stopping the script, append
|| trueor capture its output:cmd || true.
Error Handling Patterns
Strict mode catches many issues, but you still need intentional error handling for expected failure points.
Check Before You Act
1#!/bin/bash 2set -euo pipefail 3 4if [ ! -d /some/dir ]; then 5 echo "Error: Directory /some/dir does not exist." >&2 6 exit 1 7fi
Using || for Inline Error Handling
1#!/bin/bash 2set -euo pipefail 3 4mkdir -p /some/dir || { echo "Failed to create directory" >&2; exit 1; }
The || operator runs the block on the right only if the command on the left fails. This is concise for simple checks.
Functions with Built-in Validation
1#!/bin/bash 2set -euo pipefail 3 4backup_file() { 5 local src=$1 6 local dest=$2 7 8 if [ ! -f "$src" ]; then 9 echo "Error: Source file not found: $src" >&2 10 return 1 11 fi 12 13 cp "$src" "$dest" || { 14 echo "Error: Failed to copy $src to $dest" >&2 15 return 1 16 } 17 18 echo "Backup successful: $dest" 19 return 0 20} 21 22backup_file "/etc/nginx/nginx.conf" "/backups/nginx.conf"
The die Function Pattern
A reusable error function makes scripts cleaner and more consistent:
1#!/bin/bash 2set -euo pipefail 3 4die() { 5 echo "[ERROR] $*" >&2 6 exit 1 7} 8 9warn() { 10 echo "[WARN] $*" >&2 11} 12 13info() { 14 echo "[INFO] $*" 15} 16 17# Usage 18[ -f "config.yml" ] || die "config.yml not found" 19[ "$EUID" -eq 0 ] || die "This script must be run as root" 20 21info "Starting deployment..." 22mkdir -p /opt/app || die "Failed to create /opt/app" 23cp app.tar.gz /opt/app/ || die "Failed to copy application" 24info "Deployment complete."
Handling Expected Failures
Sometimes a command is allowed to fail. Handle it explicitly:
1#!/bin/bash 2set -euo pipefail 3 4# Kill a process if it exists, but don't fail if it doesn't 5if pgrep myapp > /dev/null; then 6 kill $(pgrep myapp) 7else 8 echo "myapp was not running." 9fi 10 11# Or more concisely: 12pkill myapp || true
Debugging Techniques
When a script misbehaves, you need visibility into what it's actually doing. Bash provides several built-in debugging mechanisms.
set -x — Trace Mode
Enable trace mode to print every command before it's executed, with variables expanded:
1#!/bin/bash 2 3set -x # Enable tracing 4echo "Starting process..." 5name="Alice" 6echo "Hello, $name" 7mkdir -p /tmp/test 8set +x # Disable tracing 9 10echo "Back to normal."
Output:
+ echo 'Starting process...'
Starting process...
+ name=Alice
+ echo 'Hello, Alice'
Hello, Alice
+ mkdir -p /tmp/test
+ set +x
Back to normal.
set -v — Verbose Mode
While -x shows expanded commands, -v prints the raw command lines as they're read:
1#!/bin/bash 2 3set -v 4echo "Line 1" 5echo "Line 2" 6set +v
Use -v when you suspect syntax errors or want to see the exact script source being executed.
Custom Debug Output with >&2
Redirect diagnostic messages to stderr so they don't interfere with stdout data pipelines:
1#!/bin/bash 2 3debug() { 4 echo "[DEBUG] $*" >&2 5} 6 7debug "Current user: $(whoami)" 8debug "Working directory: $(pwd)"
Logging Function with Timestamps
1#!/bin/bash 2 3log() { 4 local level=$1 5 shift 6 echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a script.log 7} 8 9log "INFO" "Script started" 10log "DEBUG" "Loading configuration..." 11log "ERROR" "Connection failed"
Customizing the Trace Prompt (PS4)
The PS4 variable controls what set -x displays before each line. Make it more useful:
1#!/bin/bash 2 3export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:+${FUNCNAME[0]}(): } ' 4set -x 5 6# Now traces show: + script.sh:15:main(): echo "hello"
Running a Script in Debug Mode Without Modifying It
1# Trace entire script 2bash -x script.sh 3 4# Verbose mode 5bash -v script.sh 6 7# Both 8bash -xv script.sh 9 10# Trace specific sections by using set -x inside the script
Trap for Cleanup
Even with perfect error handling, scripts can be interrupted — by the user pressing Ctrl+C, by a kill signal, or by a system shutdown. The trap command ensures your cleanup code runs no matter how the script exits.
Basic Cleanup Trap
1#!/bin/bash 2set -euo pipefail 3 4TEMP_DIR=$(mktemp -d) 5trap 'rm -rf "$TEMP_DIR"' EXIT 6 7echo "Using temp directory: $TEMP_DIR" 8# ... do work ...
When the script exits — whether normally, by error, or by signal — the rm -rf command runs automatically.
Handling Multiple Signals
1#!/bin/bash 2set -euo pipefail 3 4TEMP_DIR=$(mktemp -d) 5LOG_FILE="/tmp/script_$$.log" 6 7cleanup() { 8 local exit_code=$? 9 echo "" 10 echo "Cleaning up resources..." 11 rm -rf "$TEMP_DIR" 12 [ -f "$LOG_FILE" ] && rm -f "$LOG_FILE" 13 echo "Cleanup complete. Exit code: $exit_code" 14 exit $exit_code 15} 16 17trap cleanup EXIT INT TERM ERR 18 19# INT = Ctrl+C 20# TERM = kill command 21# ERR = command failure (when set -E is enabled) 22# EXIT = always runs on script exit
Trap with set -E for ERR Handling
By default, trap ERR doesn't fire in functions when set -e is active. Enable set -E to propagate ERR traps into functions:
1#!/bin/bash 2set -Eeuo pipefail 3 4on_error() { 5 local line=$1 6 echo "Error on line $line" >&2 7} 8 9trap 'on_error $LINENO' ERR
Practical Example: Safe File Processing Pipeline
1#!/bin/bash 2set -euo pipefail 3 4INPUT_FILE="${1:-}" 5OUTPUT_FILE="processed_$(basename "$INPUT_FILE")" 6TEMP_FILE=$(mktemp) 7 8if [ -z "$INPUT_FILE" ] || [ ! -f "$INPUT_FILE" ]; then 9 echo "Usage: $0 <input_file>" >&2 10 exit 1 11fi 12 13cleanup() { 14 rm -f "$TEMP_FILE" 15 # Only remove output if it wasn't successfully created 16 if [ -f "$TEMP_FILE" ] && [ ! -s "$OUTPUT_FILE" ]; then 17 rm -f "$OUTPUT_FILE" 18 fi 19} 20 21trap cleanup EXIT INT TERM 22 23echo "Processing $INPUT_FILE..." 24 25# Simulate multi-step processing 26grep -v "^#" "$INPUT_FILE" > "$TEMP_FILE" 27sort "$TEMP_FILE" | uniq > "$OUTPUT_FILE" 28 29echo "Output written to: $OUTPUT_FILE"
Real-World Example: Production Deployment Script
Here's a complete script that demonstrates strict mode, error handling, logging, debugging, and cleanup working together:
1#!/bin/bash 2 3set -euo pipefail 4 5# ─── Configuration ─── 6readonly APP_NAME="myapp" 7readonly DEPLOY_DIR="/opt/$APP_NAME" 8readonly BACKUP_DIR="/backups/$APP_NAME" 9readonly REQUIRED_USER="deploy" 10readonly LOG_FILE="/var/log/${APP_NAME}_deploy.log" 11 12# ─── Logging ─── 13log() { 14 local level=$1 15 shift 16 local message="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" 17 echo "$message" | tee -a "$LOG_FILE" 18} 19 20info() { log "INFO" "$@"; } 21error() { log "ERROR" "$@" >&2; } 22die() { error "$@"; exit 1; } 23 24# ─── Validation ─── 25validate_environment() { 26 info "Validating environment..." 27 28 [ "$(whoami)" = "$REQUIRED_USER" ] || die "Must run as user: $REQUIRED_USER" 29 [ -d "$DEPLOY_DIR" ] || die "Deploy directory missing: $DEPLOY_DIR" 30 31 local available_mb 32 available_mb=$(df -m "$DEPLOY_DIR" | awk 'NR==2 {print $4}') 33 [ "$available_mb" -gt 100 ] || die "Insufficient disk space: ${available_mb}MB" 34 35 info "Environment validation passed." 36} 37 38# ─── Backup ─── 39create_backup() { 40 info "Creating backup..." 41 local backup_name="backup_$(date +%Y%m%d_%H%M%S).tar.gz" 42 43 mkdir -p "$BACKUP_DIR" 44 tar -czf "$BACKUP_DIR/$backup_name" -C "$(dirname "$DEPLOY_DIR")" "$(basename "$DEPLOY_DIR")" || \ 45 die "Backup failed" 46 47 info "Backup created: $backup_name" 48} 49 50# ─── Deployment ─── 51deploy() { 52 info "Starting deployment..." 53 54 # Use a staging area for atomic deployment 55 local staging_dir 56 staging_dir=$(mktemp -d) 57 trap 'rm -rf "$staging_dir"' EXIT 58 59 cp -r build/* "$staging_dir/" || die "Failed to stage files" 60 61 # Atomic swap 62 rsync -a --delete "$staging_dir/" "$DEPLOY_DIR/" || die "Rsync failed" 63 64 info "Deployment complete." 65} 66 67# ─── Main ─── 68main() { 69 validate_environment 70 create_backup 71 deploy 72 info "All operations completed successfully." 73} 74 75main "$@"
Best Practices for Error Handling & Debugging
- Always start with
set -euo pipefail— it's your first line of defense - Never ignore errors silently — if a command can fail, handle it or document why it's safe
- Print errors to stderr — use
>&2so error messages don't contaminate stdout pipelines - Use meaningful exit codes —
exit 1for general errors, custom codes for specific conditions - Write a
diefunction — consistent error formatting makes logs readable - Trap
EXITfor cleanup — temporary files, locks, and connections must be freed - Use
mktempfor temporary resources — never hardcode/tmp/myscript.tmp - Enable
set -xduring development — disable or make conditional in production - Log verbosely, fail fast — the sooner you detect an error, the less damage it causes
- Test failure paths — a script that only works in the happy path isn't production-ready
Quick Reference
1# Exit codes 2$? # Exit code of last command 3exit 1 # Exit with error 4exit 0 # Exit successfully 5 6# Strict mode 7set -e # Exit on error 8set -u # Error on unset variables 9set -o pipefail # Catch pipeline failures 10set -euo pipefail # All three combined 11 12# Error handling 13cmd || true # Allow failure 14cmd || die "message" # Fail with message 15[ -f file ] || exit 1 # Check and exit 16 17# Debugging 18set -x # Enable trace 19set +x # Disable trace 20set -v # Enable verbose 21bash -x script.sh # Run with tracing 22export PS4='+ ${LINENO}: ' # Custom trace prefix 23 24# Logging 25echo "msg" >&2 # To stderr 26echo "[INFO] msg" # Structured log 27 28# Traps 29trap 'cleanup' EXIT # Run on any exit 30trap 'cleanup' INT # Run on Ctrl+C 31trap 'cleanup' TERM # Run on kill 32trap - EXIT # Remove trap
Error handling and debugging aren't afterthoughts — they're foundational to professional scripting. A script with set -euo pipefail, thoughtful validation, clear logging, and proper cleanup is a script you can trust to run at 3 AM without waking you up.
Master these techniques, and your scripts will not only work correctly but fail correctly too — with clear messages, clean exits, and no resource leaks.
Next up: Text processing with sed and awk — the power tools for transforming and analyzing data on the command line.