How to Handle Signals in Bash

How to Handle Signals in Bash

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:

SignalNumberDefault ActionMeaning
SIGHUP1TerminateHangup — terminal closed, or reload config
SIGINT2TerminateInterrupt — sent by Ctrl+C
SIGQUIT3Core dumpQuit — sent by Ctrl+\
SIGKILL9TerminateForce kill — cannot be caught or ignored
SIGTERM15TerminatePolite request to terminate (default for kill)
SIGSTOP19StopPause the process — cannot be caught
SIGTSTP20StopPause sent by Ctrl+Z
SIGCONT18ContinueResume a stopped process
SIGUSR1 / SIGUSR210 / 12TerminateUser-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 EXIT trap 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 -p to 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 exit inside a handler unless you actually want the whole script to terminate at that point.

Security Considerations

  • Don’t ignore SIGTERM in long-running privileged scripts purely to “protect” them — this can prevent legitimate administrative shutdown and force operators to use SIGKILL, 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 EXIT trap to avoid leaving stale locks that block future script runs.

Optimization Tips

  • Prefer a single cleanup function 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

  1. Forgetting that SIGKILL and SIGSTOP can never be trapped.
  2. Not forwarding signals to background/child processes.
  3. Writing overly complex logic directly inside the trap command string instead of calling a function.
  4. Leaving temp files or locks uncleaned because no EXIT trap was registered.
  5. 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
Total
2
Shares

Leave a Reply

Previous Post
How to Debug Bash Scripts

How to Debug Bash Scripts

Next Post
How to Use Regular Expressions in Bash

How to Use Regular Expressions in Bash

Related Posts