Every running process in Linux can receive signals — notifications from the kernel or other processes asking it to stop, reload, or respond in some way. Without signal handling, your scripts leave temp files behind on Ctrl+C, corrupt output mid-write, or miss important shutdown events. With trap, you control exactly what happens.
1
Linux signals — the complete reference
| Signal | Number | Default action | Common use |
|---|---|---|---|
SIGHUP | 1 | Terminate | Terminal close; reload config in daemons |
SIGINT | 2 | Terminate | Ctrl+C — interrupt from keyboard |
SIGQUIT | 3 | Core dump | Ctrl+\ — quit with core dump |
SIGKILL | 9 | Terminate | Force kill — cannot be caught or ignored |
SIGTERM | 15 | Terminate | Polite termination — default from kill |
SIGUSR1 | 10 | Terminate | User-defined — reopen log files |
SIGUSR2 | 12 | Terminate | User-defined — toggle debug mode |
SIGCHLD | 17 | Ignore | Child process changed state |
EXIT | — | Pseudo | Bash: script exits for any reason |
ERR | — | Pseudo | Bash: any command returns non-zero |
DEBUG | — | Pseudo | Bash: before every command |
RETURN | — | Pseudo | Bash: after sourced file or function |
2
trap syntax — catch signals and run handlers
BASH
# trap 'handler_command' SIGNAL [SIGNAL ...]
# ── Single-line handler ───────────────────────────────────
trap 'echo "Caught SIGINT — exiting"' INT
trap 'echo "Caught SIGTERM"; exit 0' TERM
# ── Function handler (recommended for anything non-trivial) ─
handle_exit() {
echo "Script is exiting..."
rm -f /tmp/myapp.lock /tmp/myapp_temp.*
}
trap handle_exit EXIT
# ── Multiple signals to same handler ─────────────────────
trap handle_exit EXIT INT TERM HUP
# ── Reset a trap to default behaviour ────────────────────
trap - INT # reset SIGINT to default
trap - EXIT INT # reset multiple
# ── Ignore a signal ───────────────────────────────────────
trap '' HUP # ignore SIGHUP (e.g. stay alive after logout)
trap '' INT # ignore Ctrl+C during critical section
# ── Show current traps ────────────────────────────────────
trap -p # print all active trap commands
trap -p EXIT # print trap for specific signal
3
EXIT trap — the universal cleanup pattern
BASH
#!/usr/bin/env bash
# The EXIT trap runs no matter HOW the script exits:
# - normal completion
# - set -e error exit
# - explicit exit N
# - SIGINT (Ctrl+C)
# - SIGTERM
# This makes it the ideal place for ALL cleanup
set -euo pipefail
TMPDIR=$(mktemp -d)
LOCKFILE=/tmp/myapp.lock
START_TIME=$(date +%s)
cleanup() {
local exit_code=$?
local elapsed=$(( $(date +%s) - START_TIME ))
echo ""
echo "── Cleanup (exit=${exit_code}, elapsed=${elapsed}s) ──"
# Remove temp directory
[[ -d "${TMPDIR}" ]] && { rm -rf "${TMPDIR}"; echo " Removed tmp dir"; }
# Release lock
[[ -f "${LOCKFILE}" ]] && { rm -f "${LOCKFILE}"; echo " Released lock"; }
# Kill child processes
jobs -p | xargs -r kill 2>/dev/null || true
if (( exit_code == 0 )); then
echo " ✔ Script completed successfully"
else
echo " ✘ Script failed with exit code ${exit_code}" >&2
fi
}
trap cleanup EXIT
# Acquire lock
echo $$ > "${LOCKFILE}"
# Main work happens in TMPDIR
echo "Working in ${TMPDIR}"
# ... rest of script ...
4
Graceful shutdown — SIGTERM handler
BASH
#!/usr/bin/env bash
# Long-running worker script with graceful shutdown
SHUTDOWN=false
CURRENT_JOB=""
handle_sigterm() {
echo " SIGTERM received — will stop after current job"
SHUTDOWN=true
}
handle_sigint() {
echo " SIGINT (Ctrl+C) — stopping immediately"
[[ -n "${CURRENT_JOB}" ]] && echo " Warning: job ${CURRENT_JOB} was interrupted"
exit 130 # 128 + 2 (SIGINT)
}
handle_sigusr1() {
echo " SIGUSR1 received — dumping status"
echo " Current job: ${CURRENT_JOB:-idle}"
echo " Uptime: $(( $(date +%s) - START ))s"
}
trap handle_sigterm TERM
trap handle_sigint INT
trap handle_sigusr1 USR1
START=$(date +%s)
echo "Worker started (PID $$)"
echo " Send SIGTERM to stop gracefully"
echo " Send SIGUSR1 to dump status"
while ! "${SHUTDOWN}"; do
CURRENT_JOB="job_$(date +%s)"
echo " Processing: ${CURRENT_JOB}"
sleep 5 # simulate work
CURRENT_JOB=""
done
echo " Worker stopped cleanly"
5
Signal forwarding to child processes
BASH
#!/usr/bin/env bash
# Wrapper script that forwards signals to child process
CHILD_PID=""
forward_signal() {
local sig="${1}"
[[ -n "${CHILD_PID}" ]] && kill "-${sig}" "${CHILD_PID}" 2>/dev/null
}
trap 'forward_signal TERM' TERM
trap 'forward_signal INT' INT
trap 'forward_signal HUP' HUP
# Start child process
./long_running_app &
CHILD_PID=$!
echo "Child PID: ${CHILD_PID}"
# Wait for child — also wakes on signal
wait "${CHILD_PID}"
EXIT_CODE=$?
echo "Child exited with: ${EXIT_CODE}"
exit "${EXIT_CODE}"
# ── Protect critical section from interruption ───────────
echo "Starting database migration (DO NOT INTERRUPT)"
trap '' INT TERM # ignore signals during critical section
./run_migration.sh
trap - INT TERM # restore default handlers
echo "Migration complete — signals restored"
Terminal output
Key
Signal received
Clean exit
Status info
vriddh@prod-01:~/scripts$./worker.sh &
Worker started (PID 18421)
Send SIGTERM to stop gracefully
Processing: job_1746360842
Processing: job_1746360847
vriddh@prod-01:~/scripts$kill -USR1 18421
SIGUSR1 received — dumping status
Current job: job_1746360847
vriddh@prod-01:~/scripts$kill 18421
SIGTERM received — will stop after current job
Worker stopped cleanly
█
✔ Trap rules — Always use
trap cleanup EXIT — it runs for every exit including set -e failures. Capture $? at the start of the handler before it's overwritten. Use SIGTERM for graceful shutdown requests and SIGUSR1/2 for custom events like reload or status dump. Never rely on signals alone for cleanup — the EXIT pseudo-signal covers everything.