ðŸŠĪ trap — Signal Handling in Bash Scripts

Purpose: The trap builtin tells bash what to do when a signal or special event is received — clean up temp files, log a message, ignore Ctrl-C, or perform an orderly shutdown. It is one of the most important tools for writing robust, production-quality shell scripts.
📚 Signal handling patterns are also covered in the advanced section of Deep Dive into BASH. This page focuses on practical everyday use of trap.
Syntax:

trap 'COMMANDS' SIGNAL [SIGNAL ...]
trap FUNCTION_NAME SIGNAL [SIGNAL ...]
trap '' SIGNAL    # ignore the signal
trap - SIGNAL     # reset to default behavior
trap             # list all current traps

Common Signals for Scripting

SIGINT (2)

Ctrl-C — user interrupt. Most common trap target.

SIGTERM (15)

Polite termination request. Default kill signal.

SIGHUP (1)

Terminal closed or daemon reload request.

SIGQUIT (3)

Ctrl-\ — quit with core dump.

EXIT

Pseudo-signal — fires on any script exit, clean or not.

ERR

Pseudo-signal — fires when any command returns non-zero.

DEBUG

Pseudo-signal — fires before every command. Used for tracing.

RETURN

Fires when a function or sourced file returns.

Signal names vs numbers: You can use either — trap ... SIGINT, trap ... INT, and trap ... 2 are all equivalent. Names are more readable and portable. Use kill -l to list all signals on your system.

Examples

Example 1 — Cleanup Temp Files on Exit

The most essential pattern — guarantee cleanup no matter how the script ends:

#!/bin/bash

# Create temp file
TMPFILE=$(mktemp /tmp/myapp.XXXXXX)

# Trap EXIT — runs on ANY exit: normal, Ctrl-C, error, kill
trap 'rm -f "$TMPFILE"; echo "Cleaned up."' EXIT

# Script work happens here
echo "Working... temp file is $TMPFILE"
some_long_process > "$TMPFILE"
cat "$TMPFILE"

# No need to manually clean up — trap handles it
ðŸ’Ą EXIT is your best friend: Trapping EXIT instead of individual signals means cleanup runs on every exit path — normal completion, Ctrl-C, unhandled error, or kill. One trap covers all cases.

Example 2 — Cleanup Function (Cleaner Style)

For anything beyond one line, define a function and trap it by name:

#!/bin/bash

TMPDIR=$(mktemp -d /tmp/myapp.XXXXXX)
LOCKFILE=/var/run/myapp.lock

cleanup() {
    local exit_code=$?
    echo "Exiting with code $exit_code — cleaning up..."
    rm -rf "$TMPDIR"
    rm -f "$LOCKFILE"
    exit $exit_code
}

trap cleanup EXIT

# Rest of script
touch "$LOCKFILE"
echo "Running..." > "$TMPDIR/work.txt"
do_some_work
Preserve exit code: Capture $? at the top of your cleanup function before any other commands change it, then pass it to the final exit call. Otherwise your script always exits 0 even on failure.

Example 3 — Handle Ctrl-C Gracefully

#!/bin/bash

interrupted=false

handle_interrupt() {
    echo ""
    echo "Caught Ctrl-C — finishing current item then stopping..."
    interrupted=true
}

trap handle_interrupt SIGINT

for item in /data/files/*.log; do
    if $interrupted; then
        echo "Stopping early at user request."
        break
    fi
    echo "Processing: $item"
    process_log "$item"
done

echo "Done."
Soft interrupt pattern: Rather than stopping immediately on Ctrl-C, this sets a flag and finishes the current iteration cleanly. Good for scripts processing files or database records where partial writes are worse than a brief delay.

Example 4 — Ignore a Signal

An empty string as the command tells bash to ignore the signal entirely:

#!/bin/bash

# Ignore Ctrl-C during a critical section
trap '' SIGINT SIGTERM

echo "Running critical section — cannot be interrupted..."
perform_database_migration
sync_filesystems

# Restore default behavior afterward
trap - SIGINT SIGTERM
echo "Critical section complete. Ctrl-C restored."
⚠ïļ Use sparingly: Ignoring signals means the user cannot stop a runaway script. Always restore default behavior with trap - SIGNAL after the critical section, and keep critical sections as short as possible.

Example 5 — Trap ERR for Automatic Error Detection

#!/bin/bash
set -o errexit   # exit on error
set -o pipefail  # catch pipe failures

error_handler() {
    local exit_code=$?
    local line_number=$1
    echo "ERROR: Command failed at line $line_number (exit code: $exit_code)" >&2
    echo "Script: $0" >&2
}

# Pass the line number via LINENO
trap 'error_handler $LINENO' ERR

echo "Step 1: Connecting to database..."
mysql -u app -p"$DB_PASS" mydb -e "SELECT 1"   # if this fails, ERR fires

echo "Step 2: Running migration..."
mysql -u app -p"$DB_PASS" mydb < migration.sql

echo "All steps completed successfully."
ðŸ’Ą LINENO: Passing $LINENO to the error handler gives you the exact line where the failure occurred — invaluable for debugging long scripts. Note that LINENO must be passed at trap time (in the trap string), not inside the function, or it will always be the function's line number.

Example 6 — Multiple Signals, One Handler

#!/bin/bash

RECEIVED_SIGNAL=""

signal_handler() {
    RECEIVED_SIGNAL=$1
    echo "Received signal: $RECEIVED_SIGNAL" >&2
}

trap 'signal_handler SIGINT'  SIGINT
trap 'signal_handler SIGTERM' SIGTERM
trap 'signal_handler SIGHUP'  SIGHUP

echo "PID: $$ — running. Send signals to test."
while true; do
    sleep 5
    if [[ -n "$RECEIVED_SIGNAL" ]]; then
        echo "Handling $RECEIVED_SIGNAL — shutting down."
        break
    fi
done

Example 7 — Trap in a Long-Running Daemon Script

A realistic pattern for scripts that run continuously and need orderly shutdown:

#!/bin/bash
# disk-monitor.sh — polls disk usage, stops cleanly on SIGTERM

LOGFILE=/var/log/disk-monitor.log
RUNNING=true

log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOGFILE"; }

shutdown_handler() {
    log "SIGTERM received — shutting down disk monitor."
    RUNNING=false
}

trap shutdown_handler SIGTERM SIGINT

log "Disk monitor started (PID $$)"

while $RUNNING; do
    df -h | grep -v tmpfs >> "$LOGFILE"
    log "---"
    sleep 300
done

log "Disk monitor exited cleanly."
ðŸ’Ą Systemd integration: When this script runs as a systemd service, systemctl stop sends SIGTERM. The trap catches it, sets RUNNING=false, and the script exits after its current sleep — a clean shutdown that systemd will report as successful.

Example 8 — List, Reset, and Debug Traps

# List all active traps in the current shell
trap

# Reset a single signal to default
trap - SIGINT

# Reset ALL signals to default
trap - EXIT ERR SIGINT SIGTERM SIGHUP

# Trap DEBUG to trace every command (heavy — for debugging only)
trap 'echo "DEBUG: line $LINENO: $BASH_COMMAND"' DEBUG

# Turn off DEBUG trap
trap - DEBUG

Quick Reference

PatternWhat it does
trap 'cleanup' EXITRun cleanup on any exit — the most useful trap
trap 'fn' SIGINT SIGTERMCatch Ctrl-C and kill
trap '' SIGINTIgnore Ctrl-C
trap - SIGINTRestore default Ctrl-C behavior
trap 'fn $LINENO' ERRCatch any command failure with line number
trap 'fn' DEBUGTrace every command (debug mode)
trapList all current traps
trap - EXIT ERR SIGINTReset multiple signals at once

Common Pitfalls

PitfallFix
Trap not firing on subshell exit Traps are not inherited by subshells. Re-declare inside ( ) if needed.
Cleanup runs but exit code is always 0 Capture local exit_code=$? at top of cleanup function and pass to exit $exit_code.
LINENO wrong inside error function Pass $LINENO in the trap string: trap 'fn $LINENO' ERR not inside the function.
trap '' vs trap - '' = ignore signal. - = restore default. Easy to mix up.
ERR trap fires inside functions Add set -o errtrace (-E) to make ERR trap inherit into functions.
Signal ignored in sleep/wait Use wait instead of sleep in loops — wait is interruptible by signals, sleep may not be.
Best practice summary: