A script I wrote years ago used to leave behind temporary files scattered all over /tmp whenever someone hit Ctrl+C mid-run. It wasn’t a huge problem, but it was messy, and cleaning it up manually got old fast. The fix was embarrassingly simple once I found it: the trap command. I want to walk you through everything I’ve learned about trap since then, because once it clicks, you’ll wonder how you wrote reliable scripts without it.
What Is the ‘trap’ Command
trap lets a Bash script intercept signals — like the interrupt signal sent when someone presses Ctrl+C — and run a specific command or function in response, instead of letting the script die abruptly or ignore the signal entirely. It’s Bash’s built-in mechanism for handling cleanup, logging, and graceful shutdowns.
The basic syntax is:
trap 'commands' SIGNAL
Where SIGNAL can be a signal name like INT, TERM, EXIT, or a signal number.
Why Signals Matter in Shell Scripts
Every running process on a Unix-like system can receive signals from the operating system or from other processes. Some common ones:
SIGINT(2) — sent when you press Ctrl+C.SIGTERM(15) — the default signal sent bykillto politely ask a process to stop.SIGKILL(9) — a forceful kill signal that cannot be trapped or ignored.SIGHUP(1) — sent when a terminal closes.EXIT— not a real OS signal, but a pseudo-signal Bash provides that fires whenever the script exits, for any reason.
Understanding this list is important because trap only works on signals that can actually be caught — SIGKILL and SIGSTOP are intentionally untrappable by the OS.
Step 1: A Basic Trap Example
Let’s start simple. Save this as trap_demo.sh:
#!/usr/bin/env bash
trap 'echo "Caught interrupt! Exiting cleanly."; exit 1' SIGINT
echo "Running... press Ctrl+C to test the trap."
while true; do
sleep 1
done
Run it, then press Ctrl+C. Instead of the script dying silently, you’ll see:
Running... press Ctrl+C to test the trap.
Caught interrupt! Exiting cleanly.
Explaining How This Works Internally
When Bash starts, it registers default signal handlers for the shell itself. trap overrides that default behavior for the current shell and any subshells that don’t redefine it. In the example above:
trap '...' SIGINTtells Bash: “when this script receives SIGINT, run the quoted command string instead of the default behavior (which is usually to terminate immediately).”- The command string is evaluated exactly as if you’d typed it at that point in the script, which is why you can chain multiple commands with
;. - Once the trap fires and
exit 1runs, the script terminates with exit status 1, which downstream scripts or monitoring tools can check.
Step 2: Using the EXIT Pseudo-Signal for Cleanup
This is the pattern I use in almost every non-trivial script now. The EXIT trap fires no matter how the script ends — whether it finishes normally, hits an error, or gets interrupted.
#!/usr/bin/env bash
set -euo pipefail
TMPFILE=$(mktemp)
cleanup() {
echo "Cleaning up temporary file: $TMPFILE"
rm -f "$TMPFILE"
}
trap cleanup EXIT
echo "Working with $TMPFILE"
echo "some data" > "$TMPFILE"
sleep 3
echo "Done."
No matter whether the script completes, errors out, or is interrupted with Ctrl+C, the cleanup function guarantees the temp file gets removed. This single pattern has saved me from dozens of leftover files and half-finished lock files over the years.
Step 3: Trapping Multiple Signals
You can register the same or different handlers for multiple signals in one call:
trap 'echo "Interrupted"; cleanup; exit 130' SIGINT SIGTERM
Here, both Ctrl+C (SIGINT) and a kill command (SIGTERM) trigger the same cleanup logic. I use exit 130 because that’s the conventional exit code for a script terminated by SIGINT (128 + signal number 2), which makes it easier for calling scripts or CI systems to understand what happened.
Step 4: Debugging with the DEBUG Trap
trap isn’t only for signals — it also supports pseudo-events like DEBUG, which fires before every command executes. This is fantastic for lightweight tracing:
#!/usr/bin/env bash
trap 'echo "About to run: $BASH_COMMAND"' DEBUG
x=1
y=2
echo "Sum: $((x + y))"
Output:
About to run: x=1
About to run: y=2
About to run: echo "Sum: $((x + y))"
Sum: 3
$BASH_COMMAND is a special variable that always holds the command currently being executed, and it’s especially useful inside DEBUG and ERR traps.
Step 5: Catching Errors with the ERR Trap
Combined with set -e, an ERR trap lets you catch and log the exact command that failed:
#!/usr/bin/env bash
set -euo pipefail
trap 'echo "Error on line $LINENO: command \"$BASH_COMMAND\" failed with exit code $?"' ERR
echo "Starting script"
false
echo "This line never runs"
Output:
Starting script
Error on line 6: command "false" failed with exit code 1
$LINENO gives you the exact line number, which turns a vague failure into something you can immediately locate and fix.
Real-World Use Cases
- Temporary file cleanup — as shown above, guaranteeing temp files and lock files are removed regardless of how a script ends.
- Releasing locks — if a script acquires a lock file to prevent concurrent runs, an
EXITtrap ensures the lock is released even on failure. - Graceful shutdown of long-running services — a monitoring script can trap
SIGTERMto finish its current task before exiting, rather than dying mid-operation. - Audit logging — recording when and why a script terminated, useful for debugging scheduled jobs that fail silently.
- Restoring terminal settings — scripts that change terminal behavior (like disabling echo for password prompts) should trap EXIT to restore the original settings even if the script is interrupted.
Automation Example: A Safe Lock File Pattern
#!/usr/bin/env bash
set -euo pipefail
LOCKFILE="/tmp/myscript.lock"
if [ -e "$LOCKFILE" ]; then
echo "Script already running (lock file exists). Exiting."
exit 1
fi
touch "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT
echo "Doing important work..."
sleep 5
echo "Work complete."
This is a pattern I reuse in almost every cron job that shouldn’t run twice at once. Without the trap, an interrupted run would leave the lock file behind forever, blocking every future run until someone manually deletes it.
Best Practices
- Always define your cleanup function before setting the trap, so there’s no risk of referencing something that doesn’t exist yet.
- Prefer named functions over long inline command strings for anything beyond a single command — it’s easier to read and debug.
- Combine
EXITtraps withset -euo pipefailfor the most predictable and robust scripts. - Reset a trap with
trap - SIGNALif you need to temporarily disable it partway through a script. - Remember that traps set inside a function only apply to the current shell, not automatically to subshells or background jobs, unless explicitly propagated.
Troubleshooting Common Issues
My trap doesn’t fire when the script is killed with kill -9 — This is expected. SIGKILL (signal 9) cannot be trapped, ignored, or handled in any way; it’s designed by the OS to guarantee a process can always be terminated.
The EXIT trap runs twice — This usually happens when a script calls exit inside a function that’s already inside another trap. Double-check you’re not triggering the same trap recursively.
My trap works in the terminal but not in cron — Cron jobs don’t have a controlling terminal, so signal behavior can differ slightly. Test using bash -x script.sh inside a cron-like non-interactive shell to confirm behavior.
Trap seems to ignore later redefinitions — If you call trap more than once for the same signal, the most recent call overrides the previous one; it doesn’t stack.
Common Mistakes to Avoid
- Forgetting that
SIGKILLandSIGSTOPcannot be trapped — don’t waste time debugging a “broken” trap for these. - Writing complex logic directly inline in the
trapstring instead of calling a function, which makes scripts hard to read. - Not testing what happens when the script is interrupted, only testing the “happy path.”
- Assuming a trap set in a parent shell automatically applies inside a subshell — it doesn’t, unless you explicitly re-declare it there.
Frequently Asked Questions
Can I trap more than one type of event with different handlers? Yes, just call trap multiple times with different signal names, each with its own handler.
Does ‘trap’ work the same way in all shells? The core concept exists in POSIX shells generally, but syntax and available pseudo-signals like DEBUG and ERR can vary. This article focuses on Bash-specific behavior.
How do I remove a trap I previously set? Use trap - SIGNAL, for example trap - SIGINT, which restores the default behavior for that signal.
What’s the difference between trapping EXIT and trapping SIGTERM? EXIT fires whenever the script ends for any reason (normal completion, error, or signal). SIGTERM only fires when the process specifically receives that termination signal. Using both together covers more scenarios than either alone.
Summary
The trap command turns a fragile script into a resilient one. Once you start using EXIT traps for cleanup, ERR traps for debugging, and SIGINT/SIGTERM traps for graceful shutdowns, you’ll notice your scripts fail more gracefully and leave far less mess behind. It’s a small addition to your scripting toolkit that pays for itself the first time a script gets interrupted mid-run.
