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.
trap.
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.
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.
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 instead of
individual signals means cleanup runs on every exit path â
normal completion, Ctrl-C, unhandled error, or kill.
One trap covers all cases.
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
$? 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.
#!/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."
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."
trap - SIGNAL
after the critical section, and keep critical sections as short as possible.
#!/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 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.
#!/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
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."
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.
# 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
| Pattern | What it does |
|---|---|
| trap 'cleanup' EXIT | Run cleanup on any exit â the most useful trap |
| trap 'fn' SIGINT SIGTERM | Catch Ctrl-C and kill |
| trap '' SIGINT | Ignore Ctrl-C |
| trap - SIGINT | Restore default Ctrl-C behavior |
| trap 'fn $LINENO' ERR | Catch any command failure with line number |
| trap 'fn' DEBUG | Trace every command (debug mode) |
| trap | List all current traps |
| trap - EXIT ERR SIGINT | Reset multiple signals at once |
| Pitfall | Fix |
|---|---|
| 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. |
EXIT for cleanup â it covers every exit path$? at the start of your cleanup functiontrap ERR with set -e and set -o pipefailtrap - after ignoring them