How to Use the ‘trap’ Command in Bash

How to Use the 'trap' Command in Bash

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 by kill to 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 '...' SIGINT tells 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 1 runs, 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 EXIT trap ensures the lock is released even on failure.
  • Graceful shutdown of long-running services — a monitoring script can trap SIGTERM to 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 EXIT traps with set -euo pipefail for the most predictable and robust scripts.
  • Reset a trap with trap - SIGNAL if 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 SIGKILL and SIGSTOP cannot be trapped — don’t waste time debugging a “broken” trap for these.
  • Writing complex logic directly inline in the trap string 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.

References

Total
2
Shares

Leave a Reply

Previous Post
How to Create a Bash File Transfer Utility

How to Create a Bash File Transfer Utility

Next Post
How to Create a Bash File Encryption Tool

How to Create a Bash File Encryption Tool

Related Posts