A trap lets a shell arrange an action for a signal or a shell event. It is useful for removing temporary files and coordinating shutdown, but it does not make interruption atomic or guarantee cleanup under every termination. In Bash, trap is a builtin, and EXIT is a shell event rather than an operating system signal. A reliable design names the exits it handles and preserves evidence of why execution stopped.
Separate a signal handler from the exit handler
trap 'cleanup' INT TERM replaces the default action for those signals. If the handler simply returns, the shell can continue executing; installing a handler does not inherently mean shutdown. One clear Bash pattern has the signal handlers explicitly exit, with a shared EXIT handler doing cleanup. Normal completion and explicit exit also reach that handler.
Returning from a function or a sourced file is not the same as terminating the shell. A top-level return in an ordinary executed script is not a general exit mechanism. Nor should a script assume that an unhandled fatal signal will always trigger EXIT across shells. Handle the intended termination signals explicitly and test with the actual interpreter used by the service.
trap - INT restores the default action; trap '' INT ignores the signal. They are different policies. Noninteractive shells can also inherit ignored signals that they cannot reset or trap, so launcher behavior matters. Inspect registered actions with trap -p when diagnosis requires confirming what was installed.
Preserve the reason for leaving
At entry to the exit handler, save $? before another command overwrites it. Cleanup that ends with an unconditional exit 1 converts successful work into failure and hides distinct error codes. Exiting with the saved value preserves the initiating outcome. Cleanup failures still need visible diagnostics and a deliberate policy, especially if the main work succeeded.
The example chooses 130 for handled INT and 143 for handled TERM, following Bash’s familiar signal-status convention. Those are explicit normal exits, not proof that the process was actually killed by a signal. A supervisor using the process API can distinguish the two. Here, disabling the exit trap and ignoring INT and TERM during the short cleanup prevents those handlers from reentering it.
When it breaks
SIGKILL cannot be caught, and SIGSTOP cannot be caught or used as a cleanup opportunity. Power loss, crashes, and forced termination can leave resources behind. A trap is therefore not a replacement for crash-safe writes, transactional updates, or a separately defined recovery procedure. Restrict removal to paths the script owns; a partially initialized variable must never become a broad deletion target.
Timing also matters. Bash can defer a trapped signal while waiting for a synchronous foreground command to finish. When it is waiting through the wait builtin for an asynchronous command, a trapped signal instead interrupts that wait before the handler runs. Registering a trap does not establish an immediate shutdown deadline for arbitrary blocking work.
Terminal Ctrl-C sends INT to the foreground process group, whereas signaling one PID targets that process. A child may therefore see the signal in one test but not the other. Scripts managing workers must define signal forwarding, waiting, and ownership of those workers. Killing unrelated processes or treating the shell’s exit as proof that every descendant stopped is not cleanup.
Check it quickly
Run this with Python 3, Bash, and standard Unix utilities. Each case starts a separate Bash process that creates one file in a private temporary directory. Signals target only that new shell itself. Python owns the outer directory and removes the deliberately retained KILL artifact afterward.
import os
import signal
import subprocess
import tempfile
from pathlib import Path
script = r'''
demo_file=$1
cleanup() {
demo_status=$?
trap - EXIT
trap '' INT TERM
rm -f -- "$demo_file"
printf 'cleanup=%s\n' "$demo_status"
exit "$demo_status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
: > "$demo_file" || exit 1
case "$2" in
normal) exit 7 ;;
*) kill "-$2" "$$" ;;
esac
exit 99
'''
with tempfile.TemporaryDirectory(prefix="bash-trap-demo-") as directory:
for mode, expected in [("normal", 7), ("TERM", 143),
("INT", 130), ("KILL", -signal.SIGKILL)]:
target = Path(directory) / mode
result = subprocess.run(
["bash", "--noprofile", "--norc", "-c", script,
"trap-demo", str(target), mode],
env=dict(os.environ, BASH_ENV=""),
text=True, capture_output=True, timeout=5,
)
cleaned = result.stdout == f"cleanup={expected}\n"
remains = target.exists()
assert result.returncode == expected, result
assert (cleaned, remains) == (mode != "KILL", mode == "KILL"), result
assert not result.stderr, result
print(f"{mode}: rc={result.returncode} cleaned={cleaned} remains={remains}")
Expect return codes 7, 143, and 130 with cleanup observed and no remaining file. KILL yields Python’s negative signal return code, normally -9, with no cleanup output and a remaining file. An invoking shell would conventionally report 137 instead. The contrast verifies preserved status and the uncatchable boundary; it does not test worker forwarding, terminal process groups, or delayed traps around long-running commands.
Sources
- Bash signals, signal handling while waiting and foreground process groups, checked 2026-09-07.
- POSIX trap, exit events, inherited signal dispositions, and trap actions, checked 2026-09-07.