skip to content

In a bash script, `trap cleanup EXIT` is the usual way to guarantee cleanup. Which ways of the script ending actually run that EXIT trap, and which ones do not?

level: middleimportance: must knowfreq 62%

answer

  1. fires whenever the shell itself exits
  2. explicit exit counts, even inside a function
  3. kill -9 gives bash no chance
  4. one handler on TERM and EXIT runs twice
  5. status survives unless the trap exits

basics

~20 s

Bash runs an EXIT trap whenever the shell itself decides to exit: the end of the script, any explicit exit, an abort under set -e, and after a fatal signal such as SIGTERM. SIGKILL leaves it unrun.

solid answer

~50 s

`EXIT` is a pseudo-signal meaning "just before this shell process terminates", so the trap fires on every path where bash still gets to run code: falling off the last line, an `exit` anywhere including inside a function, an abort under `set -e` or `set -u`, and termination by a signal bash did not otherwise handle — send SIGTERM to a script whose only trap is on EXIT and the trap still runs, then the shell exits 143. It does not fire when the process is destroyed outright: `kill -9`, an OOM kill, a power loss. It also does not carry into a separate child process, which has its own trap table. The trap body does not change the script's exit status unless it calls `exit` itself, so `trap 'true' EXIT; exit 3` still exits 3.

code

bash · 9 lines
bash
#!/usr/bin/env bash
set -euo pipefail

workdir=$(mktemp -d)
cleanup() { rm -rf "$workdir"; }
trap cleanup EXIT

echo "using $workdir"
exit 3          # cleanup still runs; the caller still sees status 3

go deeper

for a junior

Know that trap cleanup EXIT runs cleanup on the way out of the script, including on an early exit, and that kill -9 skips it. Say plainly why that beats putting rm on the last line.

for a middle

Be ready to enumerate the exit paths precisely — end of script, explicit exit, strict-mode abort, a signal bash handles — and to explain that the handler leaves the exit status alone unless it calls exit itself.

for a senior

Show the production judgment: cleanup must be idempotent because SIGKILL can skip it entirely, and a signal handler that exits 0 lies to the supervisor about why the run stopped. Re-raise instead.

for a principal

Own the boundary question: which invariants may depend on a trap at all, and which must be recoverable on the next run regardless. Argue for designing the restart path rather than adding more handlers.

## What `trap ... EXIT` actually hooks `trap cleanup EXIT` registers a command string that bash runs immediately before *this shell process* terminates. `EXIT` is not a real signal at all — it is a pseudo-signal bash accepts alongside the real names (`INT`, `TERM`, `HUP`, …), and it means "on the way out of this shell, whatever made it decide to leave". That framing answers nearly every follow-up: if bash is still alive and in control when the script ends, the trap runs; if the process is destroyed without bash executing another instruction, it cannot. ## The paths that run it - The last command of the script completes and the shell falls off the end. - An explicit `exit N` — anywhere, including inside a function, inside an `if`, or inside a loop. - An abort under strict-mode options: a command failing under `set -e`, an unset variable under `set -u`. The trap runs, and `$?` inside it is the failing status. - Termination by a signal that bash itself handles rather than dying on the spot. This one surprises people: a script whose only trap is `trap cleanup EXIT`, killed with plain `kill` (SIGTERM), still runs `cleanup` and then exits 143 (128 + 15). ```bash #!/usr/bin/env bash trap 'echo "cleaning up, \$?=$?"' EXIT false exit 3 # prints the message, and the caller still sees 3 ``` ## The paths that do not - `kill -9` (SIGKILL) and SIGSTOP are delivered by the kernel without giving the process a say, so nothing in userspace runs. A container OOM kill is a SIGKILL too. - The shell being replaced by `exec somecmd`: the bash process is gone and the new program knows nothing about your trap. - A crash of the machine, a pulled plug, a `kill -9` from a supervisor that ran out of patience after its grace period. - Any *child* process. Traps live in one shell's trap table; a command the script launched does not inherit your handler, so the parent's EXIT trap will not tidy up after a background job that outlives it. The practical consequence is that an EXIT trap is a very good cleanup mechanism and a very bad *guarantee*. The next run of the script still has to tolerate leftovers from a run that was SIGKILLed — that idempotency discipline is its own topic, but the trap is where you start. ## What it does to the exit status A common myth is that the trap body silently overwrites the script's exit status with the status of the last command in the handler. It does not: bash restores the pending exit status after the handler returns, so `trap 'true' EXIT; exit 3` still exits 3. Two real caveats: - `$?` *inside* the handler changes as the handler runs. If you want the script's status, capture it as the very first thing: `cleanup() { local rc=$?; …; }`. - An explicit `exit` *inside* the handler does override it. `trap 'exit 0' EXIT; exit 3` exits 0 — which is how people accidentally turn a failure into a success and make CI go green on a broken run. ## EXIT together with INT and TERM People often write both `trap cleanup INT TERM` and `trap cleanup EXIT` to "be safe". If the signal handler ends by calling `exit`, cleanup runs twice: once for the signal, once as the shell exits. ```bash cleanup() { echo cleanup; } trap 'cleanup; exit 1' TERM trap cleanup EXIT # SIGTERM now prints "cleanup" twice ``` Two ways out. Make `cleanup` idempotent — `rm -rf "$dir"` on a directory that may already be gone is naturally idempotent, and a guard flag covers anything that is not. Or register cleanup only on EXIT and let the signal handler re-raise, which also gives the caller an honest status: ```bash trap cleanup EXIT trap 'trap - TERM; kill -TERM "$$"' TERM # die as if untrapped: status 143 ``` That re-raise matters more than it looks. A handler that catches SIGTERM and then exits 0 tells the supervisor, the CI runner or the calling script that everything finished fine, when in fact the work was cut short. ## The shape worth memorising Register the trap immediately after you create the thing that needs cleaning, not at the top of the file before the variable exists, and keep the handler small and non-failing — the handler is the worst place to discover a bug, because by then you are already on the way out.

  • Your cleanup handler needs the script's exit status. How do you get it reliably?
    Capture it as the first statement of the handler: `cleanup() { local rc=$?; rm -rf "$dir"; return "$rc"; }`. `$?` changes as soon as the handler runs its own first command. You do not need to re-exit — bash restores the pending status after the trap unless the handler calls `exit` itself, and calling `exit` there is how people accidentally mask a failure.
  • You trap INT and TERM as well as EXIT, and cleanup now runs twice. What is the fix?
    Either make cleanup idempotent, or register it only on EXIT and have the signal handler re-raise: `trap - TERM; kill -TERM "$$"`. The re-raise is the better default because it also makes the script exit 143 rather than 0, so whatever sent the signal learns the work was cut short instead of assuming it finished.
  • Since SIGKILL can bypass the trap entirely, how do you cope with cleanup that never ran?
    Assume it will happen and design the next run to tolerate leftovers: work under a predictable path you can sweep, and make the script safe to re-run from a half-finished state. The trap covers the ordinary paths; re-run safety covers the violent ones. Relying on the trap as a guarantee is the mistake.

The EXIT trap is bash's finally block: it runs on the way out whether the body succeeded, returned early, or blew up. A kill -9 is the machine pulling the plug, and no finally survives that.

saying these in an interview costs you the question

  • Thinks putting rm at the end of the script is equivalent
  • Claims the EXIT trap still runs after kill -9
  • Believes the trap body silently rewrites the exit status
  • Registers cleanup on INT, TERM and EXIT without making it idempotent
  • Assumes a parent's EXIT trap cleans up after its child processes

context