Every process running on a Linux system can receive signals — small interrupts sent by the kernel, other processes, or a user pressing Ctrl+C — that tell it to stop, pause, reload, or clean up. If you’ve ever hit Ctrl+C to kill a script and wondered whether it left temporary files behind, you’ve already run into the world of signal handling. Writing scripts that respond gracefully to signals is what separates a fragile one-off script from a production-ready tool.
This guide covers what signals are, how to trap and handle them in Bash, and how to build scripts that clean up properly no matter how they’re terminated.
What Are Signals?
A signal is a limited form of inter-process communication used by Unix-like operating systems to notify a process that a particular event has occurred. Signals are identified by both a name and a number. Some of the most common ones:
| Signal | Number | Default Action | Meaning |
|---|---|---|---|
| SIGHUP | 1 | Terminate | Hangup — terminal closed, or reload config |
| SIGINT | 2 | Terminate | Interrupt — sent by Ctrl+C |
| SIGQUIT | 3 | Core dump | Quit — sent by Ctrl+\ |
| SIGKILL | 9 | Terminate | Force kill — cannot be caught or ignored |
| SIGTERM | 15 | Terminate | Polite request to terminate (default for kill) |
| SIGSTOP | 19 | Stop | Pause the process — cannot be caught |
| SIGTSTP | 20 | Stop | Pause sent by Ctrl+Z |
| SIGCONT | 18 | Continue | Resume a stopped process |
| SIGUSR1 / SIGUSR2 | 10 / 12 | Terminate | User-defined signals for custom behavior |
You can see the full list of signals your system supports with:
kill -l
Two signals — SIGKILL and SIGSTOP — cannot be caught, blocked, or ignored by a process. They’re handled directly by the kernel, which is why kill -9 is the “nuclear option” that always works.
The trap Command
Bash lets you intercept signals using the trap built-in. The basic syntax is:
trap 'commands' SIGNAL_NAME
When the specified signal is received, Bash runs the given commands instead of (or in addition to) the default action.
#!/bin/bash
trap 'echo "Caught SIGINT — cleaning up..."; exit 1' SIGINT
echo "Running... press Ctrl+C to test the trap"
sleep 30
How this works internally: When you press Ctrl+C, the terminal driver sends SIGINT to the foreground process group. Normally, Bash’s default response is to terminate the script immediately. With the trap registered, Bash instead pauses execution, runs the trap’s command string in the current shell context, and only then decides what to do next (in this case, we explicitly call exit 1).
Common Signal-Handling Patterns
1. Cleanup on Exit (EXIT trap)
The EXIT pseudo-signal fires whenever the script ends, whether normally, via exit, or due to an uncaught signal. This is the most reliable way to guarantee cleanup code runs.
#!/bin/bash
TMPFILE=$(mktemp)
cleanup() {
echo "Removing temp file: $TMPFILE"
rm -f "$TMPFILE"
}
trap cleanup EXIT
echo "Working with $TMPFILE"
echo "sample data" > "$TMPFILE"
sleep 5
No matter how this script ends — success, failure, or Ctrl+C — the cleanup function runs and removes the temp file.
2. Handling Multiple Signals with One Handler
#!/bin/bash
handle_signal() {
echo "Received signal, shutting down gracefully..."
exit 0
}
trap handle_signal SIGINT SIGTERM
echo "PID: $$"
while true; do
echo "Working..."
sleep 2
done
Run this, then from another terminal send: kill -TERM <pid> and watch it exit cleanly instead of being killed abruptly.
3. Ignoring a Signal
trap '' SIGINT
echo "Ctrl+C is now disabled for this script"
sleep 10
Passing an empty string as the command tells Bash to ignore the signal entirely.
4. Restoring Default Behavior
trap SIGINT # resets to default action
Calling trap with only a signal name (no command) restores the default handling.
5. Reloading Configuration with SIGHUP
Many long-running daemons use SIGHUP as a “reload configuration” signal rather than treating it as a termination signal.
#!/bin/bash
load_config() {
echo "Reloading configuration..."
source /etc/myapp/config.sh
}
trap load_config SIGHUP
load_config
while true; do
sleep 5
done
Sending kill -HUP <pid> will trigger a config reload without stopping the process.
Real-World Use Cases
Graceful Shutdown of a Background Worker
#!/bin/bash
running=true
shutdown() {
echo "Shutdown signal received. Finishing current task..."
running=false
}
trap shutdown SIGTERM SIGINT
while $running; do
echo "Processing job..."
sleep 2
done
echo "Worker stopped cleanly."
This pattern is common in queue-processing scripts: instead of dying mid-task, the loop checks a flag and exits only after the current unit of work completes.
Preventing Accidental Interruption During Critical Sections
#!/bin/bash
echo "Starting critical database backup..."
trap '' SIGINT SIGTERM # ignore interrupts during backup
pg_dump mydb > backup.sql
trap - SIGINT SIGTERM # restore default handling afterward
echo "Backup complete, signals re-enabled."
Logging Signal Events for Debugging
#!/bin/bash
for sig in SIGINT SIGTERM SIGHUP; do
trap "echo \"[$(date)] Received $sig\" >> signal.log" "$sig"
done
sleep 60
Automation and Script Robustness
Signal handling matters most in scripts meant to run unattended: systemd services, cron-launched daemons, and CI/CD pipeline steps. A script that ignores SIGTERM can hang a deployment pipeline waiting for a process that will never willingly exit, forcing an eventual SIGKILL timeout. Handling SIGTERM gracefully lets your automation shut things down predictably.
#!/bin/bash
# systemd-friendly service script
trap 'echo "Stopping service..."; exit 0' SIGTERM
echo "Service starting, PID $$"
while true; do
# do work
sleep 5
done
systemd sends SIGTERM first when stopping a unit, waits for TimeoutStopSec, then escalates to SIGKILL if the process hasn’t exited. Handling SIGTERM explicitly avoids that forced kill.
Best Practices
- Always register an
EXITtrap for cleanup tasks (temp files, lock files, background processes) — it fires in more scenarios than any individual signal trap. - Keep trap handlers short. If cleanup logic is complex, call a dedicated function rather than inlining a long command string.
- Avoid doing heavy work inside a trap handler for signals that might fire repeatedly, like
SIGCHLD. - Use
trap -pto inspect currently registered traps for debugging:trap -p - When trapping multiple signals with the same handler, list them together to avoid duplicated code:
trap handler SIGINT SIGTERM SIGHUP. - Remember that trap handlers run in the same shell — avoid
exitinside a handler unless you actually want the whole script to terminate at that point.
Security Considerations
- Don’t ignore
SIGTERMin long-running privileged scripts purely to “protect” them — this can prevent legitimate administrative shutdown and force operators to useSIGKILL, which skips cleanup and can leave resources (locks, temp files, open connections) in a bad state. - Be cautious with traps that execute commands built from external input — a poorly constructed trap string could be exploited if attacker-controlled data ends up inside it.
- Lock files created during signal-safe sections should be removed in the
EXITtrap to avoid leaving stale locks that block future script runs.
Optimization Tips
- Prefer a single
cleanupfunction referenced by multiple traps rather than duplicating logic across several trap strings — easier to maintain and less error-prone. - For scripts with background child processes, trap and forward signals to children explicitly, since child processes don’t automatically receive signals sent to the parent:
trap 'kill -TERM $child_pid' SIGTERMsome_long_command &child_pid=$!wait $child_pid
Troubleshooting Common Issues
Problem: My trap never fires. Double check the signal name spelling (SIGINT vs INT — both usually work, but be consistent) and confirm the script isn’t being killed with SIGKILL, which cannot be trapped.
Problem: Ctrl+C still kills my script even with a trap set. Make sure the trap is registered before the long-running command, and that you’re not running the command in a way that creates a new process group that doesn’t receive the same signal (common with certain nohup or setsid usages).
Problem: Background child processes don’t stop when the parent is killed. Signals aren’t automatically propagated to children. Explicitly forward the signal to child PIDs inside your trap handler, as shown above.
Problem: The EXIT trap runs twice or behaves unexpectedly. This usually happens when a signal-specific trap also calls exit, triggering the EXIT trap a second time as part of normal exit processing — this is expected, but make sure your cleanup logic is idempotent (safe to run more than once).
Common Mistakes
- Forgetting that
SIGKILLandSIGSTOPcan never be trapped. - Not forwarding signals to background/child processes.
- Writing overly complex logic directly inside the trap command string instead of calling a function.
- Leaving temp files or locks uncleaned because no
EXITtrap was registered. - Assuming a trap set inside a function persists correctly across subshells — traps are shell-specific and don’t propagate into subshells or separate processes.
Frequently Asked Questions
Can I trap SIGKILL? No. SIGKILL (and SIGSTOP) are handled directly by the kernel and cannot be caught, blocked, or ignored by any process.
What’s the difference between SIGTERM and SIGKILL? SIGTERM is a polite request that a process can catch and respond to (e.g., to clean up before exiting). SIGKILL forcibly terminates the process immediately with no chance to react.
How do I send a signal to a running script manually? Use kill -SIGNAL_NAME PID, e.g., kill -SIGTERM 12345, or kill -9 12345 for a forced kill using the signal number.
What does the EXIT trap actually catch? It’s not a real signal — it’s a Bash pseudo-event that fires whenever the shell is about to exit, for any reason, making it the most reliable place to put cleanup code.
Summary
Signal handling turns a fragile script into a resilient one. By using trap, you can intercept interruptions like Ctrl+C, respond gracefully to SIGTERM from process managers like systemd, reload configuration on SIGHUP, and guarantee cleanup code runs through an EXIT trap — regardless of how the script actually terminates. For anything that runs unattended or manages resources like temp files, locks, or child processes, proper signal handling isn’t optional polish; it’s a core part of writing dependable Bash.
References
- GNU Bash Manual — Signals: https://www.gnu.org/software/bash/manual/bash.html#Signals
- GNU Bash Manual — trap Builtin: https://www.gnu.org/software/bash/manual/bash.html#index-trap
- POSIX Signal Concepts: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap02.html#tag_02_04
- man7.org signal(7): https://man7.org/linux/man-pages/man7/signal.7.html
