Bash Trap: Signal Handling in Scripts
Unix signals are the operating system's way of interrupting running processes to notify them of events—everything from a user pressing Ctrl+C to the system shutting down. Without proper signal...
Key Insights
- The
trapcommand is essential for production Bash scripts, enabling cleanup of resources, graceful shutdowns, and proper error handling when scripts receive signals or exit unexpectedly. - Most scripts should trap at minimum EXIT, INT, and TERM signals—EXIT handles all script terminations, while INT and TERM allow graceful shutdown when users or systems request it.
- SIGKILL cannot be trapped and will immediately terminate your script, making it critical to handle SIGTERM properly for graceful shutdowns in production environments.
Introduction to Signals and the Trap Command
Unix signals are the operating system’s way of interrupting running processes to notify them of events—everything from a user pressing Ctrl+C to the system shutting down. Without proper signal handling, your scripts leave behind temporary files, orphaned processes, and half-completed operations that corrupt data or waste resources.
The trap builtin command in Bash allows you to intercept these signals and execute cleanup code before your script terminates. This isn’t optional for production scripts—it’s a fundamental requirement for reliable automation.
Here’s the difference between a script that ignores signals and one that handles them:
#!/bin/bash
# Unhandled - leaves temp files everywhere
echo "Processing data..."
echo "temporary data" > /tmp/mydata.$$
sleep 30
rm /tmp/mydata.$$
Press Ctrl+C during the sleep, and /tmp/mydata.$$ remains forever. Now with trap:
#!/bin/bash
# Properly handled
cleanup() {
echo "Cleaning up..."
rm -f /tmp/mydata.$$
}
trap cleanup EXIT
echo "Processing data..."
echo "temporary data" > /tmp/mydata.$$
sleep 30
Ctrl+C now triggers cleanup automatically. This simple pattern prevents resource leaks in production.
Basic Trap Syntax and Common Signals
The trap syntax follows this pattern: trap 'commands' SIGNAL. The commands execute when the specified signal arrives. You can specify multiple signals for the same handler or use separate handlers for different signals.
The signals you’ll trap most frequently:
- SIGINT (2): Sent when user presses Ctrl+C
- SIGTERM (15): Polite shutdown request from system or other processes
- EXIT: Pseudo-signal that triggers on any script exit, including normal completion
- ERR: Pseudo-signal that triggers when commands fail (requires
set -E)
Here’s a robust basic implementation:
#!/bin/bash
set -euo pipefail
TEMP_DIR=""
cleanup() {
local exit_code=$?
echo "Received termination signal, cleaning up..."
# Kill background jobs
jobs -p | xargs -r kill 2>/dev/null
# Remove temp directory
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
rm -rf "$TEMP_DIR"
fi
exit $exit_code
}
# Trap both INT and TERM, plus EXIT for all other exits
trap cleanup EXIT INT TERM
TEMP_DIR=$(mktemp -d)
echo "Working in $TEMP_DIR"
# Simulate long-running work
for i in {1..30}; do
echo "Processing item $i"
sleep 1
done
This script handles Ctrl+C, kill commands, and normal exits identically. The exit_code preservation ensures the script returns the correct status to calling processes.
Cleanup Operations and Resource Management
Production scripts create temporary files, acquire locks, open database connections, and spawn background processes. Every resource you acquire must be released, regardless of how the script terminates.
The EXIT trap is your safety net. It executes on normal exits, errors (with set -e), and most signals. Place all cleanup logic in one function and trap EXIT:
#!/bin/bash
set -euo pipefail
LOCK_FILE="/var/run/myapp.lock"
TEMP_FILES=()
BACKGROUND_PIDS=()
cleanup() {
echo "Cleaning up resources..."
# Kill background processes
for pid in "${BACKGROUND_PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
echo "Stopping background process $pid"
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
done
# Remove temporary files
for file in "${TEMP_FILES[@]}"; do
rm -f "$file"
done
# Release lock
rm -f "$LOCK_FILE"
}
trap cleanup EXIT
# Acquire lock
echo $$ > "$LOCK_FILE"
# Create temp files and track them
temp1=$(mktemp)
temp2=$(mktemp)
TEMP_FILES+=("$temp1" "$temp2")
# Start background worker and track it
sleep 300 &
BACKGROUND_PIDS+=($!)
# Main work here
echo "Doing work..."
sleep 10
This pattern guarantees cleanup even if the script encounters errors, receives signals, or exits normally. The array-based tracking scales to any number of resources.
Error Handling with ERR and EXIT Traps
The ERR trap executes when commands fail, but only if you’ve set set -E to inherit ERR traps in functions and subshells. This enables sophisticated error handling in complex scripts:
#!/bin/bash
set -eEuo pipefail
LOG_FILE="/var/log/backup.log"
BACKUP_DIR="/backup"
TEMP_BACKUP=""
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
error_handler() {
local line_number=$1
log "ERROR: Script failed at line $line_number"
# Rollback partial backup
if [[ -n "$TEMP_BACKUP" && -d "$TEMP_BACKUP" ]]; then
log "Rolling back partial backup: $TEMP_BACKUP"
rm -rf "$TEMP_BACKUP"
fi
# Send alert
echo "Backup failed on $(hostname)" | mail -s "Backup Failure" ops@example.com
}
cleanup() {
log "Cleanup completed"
}
trap 'error_handler $LINENO' ERR
trap cleanup EXIT
log "Starting database backup"
TEMP_BACKUP="$BACKUP_DIR/backup.$(date +%s).tmp"
mkdir -p "$TEMP_BACKUP"
# Simulate backup operations that might fail
pg_dump production_db > "$TEMP_BACKUP/database.sql"
tar -czf "$TEMP_BACKUP/files.tar.gz" /var/www/html
# Finalize backup
mv "$TEMP_BACKUP" "${TEMP_BACKUP%.tmp}"
TEMP_BACKUP="" # Clear so cleanup doesn't remove successful backup
log "Backup completed successfully"
The ERR trap captures the exact line number where failures occur, enabling precise error reporting. The EXIT trap still runs after ERR, handling final cleanup.
Advanced Patterns: Nested Traps and Signal Forwarding
When scripts spawn child processes, signals sent to the parent don’t automatically propagate to children. You must explicitly forward them:
#!/bin/bash
set -euo pipefail
WORKER_PIDS=()
cleanup() {
echo "Shutting down workers..."
# Forward TERM to all workers
for pid in "${WORKER_PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid" 2>/dev/null || true
fi
done
# Wait for graceful shutdown
local timeout=10
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local all_stopped=true
for pid in "${WORKER_PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
all_stopped=false
break
fi
done
if $all_stopped; then
echo "All workers stopped gracefully"
return
fi
sleep 1
((elapsed++))
done
# Force kill if still running
echo "Forcing worker shutdown..."
for pid in "${WORKER_PIDS[@]}"; do
kill -9 "$pid" 2>/dev/null || true
done
}
trap cleanup EXIT INT TERM
# Start worker processes
for i in {1..5}; do
(
trap 'echo "Worker $i shutting down"; exit' TERM
while true; do
echo "Worker $i processing..."
sleep 5
done
) &
WORKER_PIDS+=($!)
done
echo "Started ${#WORKER_PIDS[@]} workers"
wait
This pattern implements graceful shutdown with timeout—workers get 10 seconds to finish before forced termination. Critical for production services.
Real-World Use Cases
Container entrypoint scripts are a prime use case for trap. Containers receive SIGTERM when stopping, and you have a grace period (typically 10 seconds) before SIGKILL:
#!/bin/bash
set -euo pipefail
APP_PID=""
shutdown() {
echo "Received shutdown signal"
if [[ -n "$APP_PID" ]]; then
echo "Stopping application (PID: $APP_PID)..."
kill -TERM "$APP_PID" 2>/dev/null || true
# Wait for graceful shutdown
local count=0
while kill -0 "$APP_PID" 2>/dev/null && [[ $count -lt 30 ]]; do
sleep 1
((count++))
done
if kill -0 "$APP_PID" 2>/dev/null; then
echo "Forcing shutdown..."
kill -9 "$APP_PID" 2>/dev/null || true
fi
fi
echo "Shutdown complete"
exit 0
}
trap shutdown TERM INT
# Start application
/usr/local/bin/myapp &
APP_PID=$!
echo "Application started (PID: $APP_PID)"
# Wait for application
wait "$APP_PID"
This ensures your containerized applications shut down cleanly, flushing buffers and closing connections properly.
Best Practices and Gotchas
SIGKILL cannot be trapped. When you see kill -9, that process dies immediately with no cleanup. This is why handling SIGTERM properly is critical—it’s your only chance for graceful shutdown.
Traps don’t inherit to subshells unless you explicitly set them there. Each subshell starts with a clean slate.
Debug traps carefully. Use this template to trace trap execution:
#!/bin/bash
set -euo pipefail
export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
DEBUG=true
debug() {
if [[ "${DEBUG:-false}" == "true" ]]; then
echo "DEBUG: $*" >&2
fi
}
cleanup() {
debug "cleanup() called, exit code: $?"
debug "Removing temp files..."
# cleanup code here
}
trap 'debug "EXIT trap triggered"' EXIT
trap 'debug "ERR trap triggered at line $LINENO"' ERR
trap 'debug "INT trap triggered"' INT
# Your script here
Test signal handling. Don’t wait for production to discover your cleanup doesn’t work:
# Test normal exit
./myscript.sh
# Test Ctrl+C
./myscript.sh
# Press Ctrl+C after a few seconds
# Test SIGTERM
./myscript.sh &
PID=$!
sleep 2
kill $PID
# Test SIGKILL (cleanup shouldn't run)
./myscript.sh &
PID=$!
sleep 2
kill -9 $PID
Signal handling separates amateur scripts from production-ready automation. Every script that creates resources, spawns processes, or runs longer than a few seconds needs trap handlers. The patterns here handle 95% of real-world scenarios—implement them by default, and your scripts will be far more reliable.