| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10 |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user; no root required |
| Scope | Bash trap: EXIT cleanup, reading exit status in EXIT traps, INT/TERM/HUP, ERR with set -E, subshell inheritance limits, child signal forwarding, and trap -p. Does not cover full strict-mode rules, DEBUG/RETURN tracing, or every set -e exception. |
| Related guides | Bash subshell Shell scripting course hub |
Bash trap runs a command or function when the shell receives a signal or reaches a special event such as EXIT or ERR. Its most common use is cleanup: remove temporary files on exit, handle Ctrl+C gracefully, and stop child processes when a script receives SIGTERM.
The guarantees are narrower than many tutorials claim: EXIT is a pseudo-signal, ERR is not a general exception handler, and trapping TERM on the parent does not stop a long-running child unless you forward the signal. The sections below show reliable patterns and where they stop working.
Bash trap syntax
trap takes an action string and one or more signal names. Use trap -l to list signal numbers and names Bash understands:
trap -l | head -1Sample output:
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAPThe first line confirms SIGINT is signal 2 — what Ctrl+C sends in a typical terminal.
The basic forms are:
trap 'commands' SIGNAL— runcommandswhenSIGNALarrivestrap FUNCTION SIGNAL— call a shell function (preferred for non-trivial cleanup)trap - SIGNAL— restore the default dispositiontrap '' SIGNAL— ignore the signal until you reset ittrap -p [SIGNAL]— print the current trap for one signal or for all signals
EXIT is special: it is not a kernel signal. Bash runs an EXIT trap when the shell exits normally, executes exit, or exits because of conditions such as set -e. Signal handling is separate: an INT or TERM handler can call exit, which then causes the EXIT trap to run.
Bash also defines three other pseudo-events you may see in trap documentation:
| Event | Runs when |
|---|---|
EXIT |
The shell exits |
ERR |
A qualifying command, list, or pipeline fails |
DEBUG |
Before many commands execute; mainly used for tracing/debugging |
RETURN |
When a function or sourced script finishes |
This article focuses on EXIT and ERR. DEBUG and RETURN are mainly useful for tracing and debugging.
Clean up temporary files with trap ... EXIT
The most common pattern is pairing mktemp -d with an EXIT trap so temp directories disappear even when the script fails:
bash -c 'tmpdir=$(mktemp -d); trap "rm -rf \"$tmpdir\"" EXIT; touch "$tmpdir/marker"; ls "$tmpdir"'Sample output:
markerThe script printed marker and exited; the EXIT trap removed the directory, so nothing remains under /tmp from that run.
Register the trap immediately after creating the resource. If you create files first and trap later, a failure in between can leave debris. Point the trap at a function when cleanup grows beyond one rm line — you can read the exit status inside that function in the next section.
Read the exit status inside an EXIT trap
An EXIT trap runs before the shell terminates. Commands run by an EXIT trap do not normally replace the status Bash was already exiting with. An explicit exit n in the trap can set a different status.
A trap that succeeds does not replace a failing script status:
bash -c 'trap "true" EXIT; false'; echo parent:$?Sample output:
parent:1Likewise, a trap that fails does not turn a successful script into a failure:
bash -c 'trap "false" EXIT; true'; echo parent:$?Sample output:
parent:0Ordinary cleanup commands such as rm behave the same way — they do not automatically overwrite the status Bash was already exiting with:
bash -c 'trap "rm -rf /tmp/nonexistent 2>/dev/null" EXIT; false'; echo parent:$?Sample output:
parent:1When you need to inspect or log the status inside cleanup, read $? as the first command in the trap handler. At that moment it holds the status that caused the shell to reach the EXIT trap:
bash -c 'cleanup(){ status=$?; echo "script exited with $status"; }; trap cleanup EXIT; false'; echo parent:$?Sample output:
script exited with 1
parent:1Save that value immediately if later cleanup commands might change $?. Call exit "$status" explicitly when the handler must finish with the same status after extra logic — for example when the trap function might otherwise fall through with a different result. Callers still use Bash exit codes to judge success; the trap does not need to re-exit on every simple rm path.
Catch Ctrl+C with SIGINT
Pressing Ctrl+C in a terminal sends SIGINT to the foreground process group. In a script, register a handler with INT or SIGINT — the names are equivalent:
bash -c 'trap "echo caught_INT; exit 130" INT; kill -INT $$' 2>&1; echo exit:$?Sample output:
caught_INT
exit:130Exit 130 is the conventional status for a script stopped by SIGINT (128 + signal number 2).
When the handler does not call exit, Bash continues after the trap body:
bash -c 'trap "echo caught" INT; kill -INT $$; echo after'Sample output:
caught
afterFor cleanup on interrupt, combine EXIT with an INT trap. The INT handler can call exit 130; Bash then runs the EXIT trap before the shell actually exits:
bash -c 'tmpdir=$(mktemp -d); trap "rm -rf \"$tmpdir\"; echo removed" EXIT; trap "exit 130" INT; touch "$tmpdir/f"; ls "$tmpdir"; kill -INT $$' 2>&1; echo exit:$?Sample output:
f
removed
exit:130The temp file was created, INT triggered cleanup through EXIT, and the directory was removed.
To ignore Ctrl+C during a short critical section, use an empty trap: trap '' INT. Reset with trap - INT when the section ends. Use that sparingly — operators expect Ctrl+C to stop the foreground job.
Handle SIGTERM and SIGHUP
SIGTERM is what kill PID sends by default. SIGHUP often arrives when an SSH session drops or a controlling terminal closes. Register handlers the same way as for INT:
bash -c 'trap "echo got_HUP; exit 129" HUP; kill -HUP $$' 2>&1; echo exit:$?Sample output:
got_HUP
exit:129For TERM, a supervisor script that starts a background child must forward the signal. Trapping TERM on the parent alone does not stop sleep in another process. Save the supervisor in a short script so you can signal the Bash process directly:
#!/usr/bin/env bash
on_term() {
echo "TERM caught"
kill -TERM "$child" 2>/dev/null
wait "$child" 2>/dev/null
exit 143
}
trap on_term TERM
sleep 60 &
child=$!
wait "$child"Write that script to /tmp/trap-term-demo.sh, then start it in the background and send SIGTERM to its PID:
bash /tmp/trap-term-demo.sh & pid=$!Give the supervisor a moment to register its trap and start the child:
sleep 0.5Signal the Bash supervisor process — not a wrapper such as timeout:
kill -TERM "$pid"Wait for the supervisor to finish and read its exit status:
wait "$pid"; echo "parent exit: $?"Sample output:
TERM caught
parent exit: 143The supervisor caught TERM, sent SIGTERM to the child, waited for it, and exited with 143 (128 + 15). After forwarding, wait keeps the parent alive until the child finishes shutting down. Without forwarding, the child could keep running after the parent dies.
Traps do not propagate to arbitrary child processes — a background sleep is a separate process with its own signal disposition. See kill and pkill for sending signals by name or PID.
Signals you cannot trap
The kernel does not allow user handlers for SIGKILL or SIGSTOP. Bash cannot override that — see signal(7) for the full list of uncatchable signals. Plan cleanup with EXIT and cooperative shutdown; you cannot run a trap after kill -9.
Trap multiple signals
One function can serve several signals. Pass the signal name as an argument by quoting carefully:
bash -c 'h(){ echo signal=$1; }; trap "h TERM" TERM; trap "h INT" INT; kill -INT $$' 2>&1Sample output:
signal=INTMultiple signals can share an action, but installing a handler replaces the signal's default behavior. A cleanup-only INT or TERM trap runs your function and then returns to the script instead of terminating it:
bash -c 'cleanup(){ echo cleaning; }; trap cleanup TERM; echo started; kill -TERM $$; echo still_running'Sample output:
started
cleaning
still_runningFor shutdown handlers, call exit with the conventional status for each signal. Keep file cleanup on EXIT and exit explicitly from signal handlers:
trap cleanup EXIT
trap 'on_signal 130' INT
trap 'on_signal 143' TERMERR traps and their limitations
trap ... ERR runs when a command fails, but only under the same conditions that would trigger errexit (set -e). It is not a catch-all exception mechanism like try/catch in other languages.
ERR does not require set -e to be enabled. The rules for which failures qualify mirror errexit's exceptions, and GNU Bash documents them as the same conditions. In an if test, while condition, or part of &&/||, a failing command does not trigger ERR. For pipelines, ERR follows the same rules as errexit: set -o pipefail changes the pipeline's overall exit status when an earlier stage fails, but it does not mean each failing command in the pipe runs the trap on its own.
Without set -E, the ERR trap is not inherited into functions. In this example false runs inside f, but echo after still executes, so f returns success and the caller never sees a failure — and on_err never runs:
bash -c 'on_err(){ echo ERR_fired; }; trap on_err ERR; f(){ false; echo after; }; f; echo done'Sample output:
after
doneWithout set -E, an ERR trap does not run inside the function. If the function itself ultimately returns non-zero, however, the caller may still run its own ERR trap for the failed function command — the same pattern as a failed subshell compound command:
bash -c 'trap "echo ERR" ERR; f(){ false; }; f; echo done'Sample output:
ERR
doneEnable set -E when the same handler should run for failures inside functions before the function returns. For broader error-handling patterns, see Bash error handling.
set -E / errtrace
By default, an ERR trap does not inherit into shell functions. Enable errtrace with set -E (same option as set -o errtrace) so the trap runs when a command fails inside a function:
bash -c 'set -E; on_err(){ echo ERR_fired; }; trap on_err ERR; f(){ false; echo after; }; f; echo done'Sample output:
ERR_fired
after
doneWith set -E, ERR_fired appeared, but execution still continued because set -e was not enabled. Pair set -E with set -e when you want both the trap and immediate exit.
ERR behavior in subshells is easy to misread. Without set -E, the trap is not inherited into the ( ... ) environment. The subshell runs false and returns failure; the parent then sees the ( false ) compound command fail and runs its own ERR trap:
bash -c 'trap "echo ERR_sub" ERR; ( false ); echo after_sub'Sample output:
ERR_sub
after_subThat ERR_sub line comes from the parent shell, not from a trap inside the subshell.
With set -E, the inherited trap can fire twice for one failing subshell:
bash -c 'set -E; trap "echo ERR_sub" ERR; ( false ); echo after_sub'Sample output:
ERR_sub
ERR_sub
after_subThe first line runs when false fails inside the subshell and the inherited ERR trap executes there. The subshell itself still exits with failure, so the parent runs its ERR trap again for the failed ( ... ) command.
Command substitution with set -E can produce two visible effects from one failure:
bash -c 'set -E; trap "echo ERR" ERR; out=$(false); echo out=$out'Sample output:
ERR
out=ERRHere false fails inside $(...), the inherited trap's echo ERR is captured as stdout, and the assignment stores out=ERR. The command substitution still fails, so the parent shell's ERR trap fires too and prints a standalone ERR. Avoid side-effect-heavy ERR handlers when failures happen inside $(...).
A trap set inside ( ... ) applies only to that subshell's lifetime. When the subshell exits, its traps disappear.
Inspect, replace and remove traps
Use trap -p to see what is registered:
bash -c 'trap "echo hi" INT; trap -p INT'Sample output:
trap -- 'echo hi' SIGINTRestore the default handler with trap - SIGNAL:
bash -c 'trap "echo hi" INT; trap - INT; trap -p INT; echo no_trap:$?'After trap - INT, trap -p printed nothing and the shell continued — the default INT behavior is back.
Avoid trap-quoting bugs
trap stores its action as a string and evaluates it later. Double quotes expand variables when the trap command is registered; single quotes defer variable expansion until the trap action executes.
A common bug is embedding $tmpdir in double quotes at registration time:
bash -c 'tmpdir=$(mktemp -d); trap "rm -rf $tmpdir" EXIT; echo trap_line=$(trap -p EXIT); touch "$tmpdir/f"'Sample output:
trap_line=trap -- 'rm -rf /tmp/tmp.UFj69NXYgt' EXITHere the path was fixed at trap time, which works for a constant temp dir. If $tmpdir were meant to change later, the trap would still remove the old path.
Prefer a function so variables are read when cleanup runs:
bash -c 'tmpdir=$(mktemp -d); cleanup(){ rm -rf "$tmpdir"; echo cleaned; }; trap cleanup EXIT; touch "$tmpdir/f"; ls "$tmpdir"'Sample output:
f
cleanedFunction traps are easier to read and less error-prone than long single-quoted strings.
Complete cleanup example
The example below uses set -euo pipefail; see the Bash strict mode guide for those options, since their exception rules are outside this article's scope.
The script combines temp-dir cleanup on EXIT with INT and TERM handlers that stop a background child. Background jobs started without job control ignore SIGINT, so both handlers send the child SIGTERM. The parent still exits with 130 for INT and 143 for TERM.
#!/usr/bin/env bash
set -euo pipefail
tmpdir=$(mktemp -d)
child=""
cleanup() {
[[ -n "$tmpdir" && -d "$tmpdir" ]] && rm -rf "$tmpdir"
}
on_signal() {
local status=$1
if [[ -n "$child" ]]; then
kill -TERM "$child" 2>/dev/null || true
wait "$child" 2>/dev/null || true
fi
exit "$status"
}
trap cleanup EXIT
trap 'on_signal 130' INT
trap 'on_signal 143' TERM
work() {
sleep 60 &
child=$!
wait "$child"
}
workRegister traps right after allocating tmpdir. Use EXIT for file cleanup and explicit exit in signal handlers when the parent must finish with status 130 or 143. When a trapped signal arrives during wait, Bash returns from wait and runs the handler; the handler then sends SIGTERM to the child and waits again.
wait lets a cooperative child finish after receiving SIGTERM. A child that ignores TERM needs separate timeout or escalation logic, which is outside this article's scope. Not every exit path guarantees cleanup (SIGKILL, hard crashes), but EXIT plus cooperative TERM shutdown covers normal script termination and operator interrupts.
Summary
Bash trap lets you run code when signals arrive or when the shell exits. trap cleanup EXIT with a function is the right default for temp files and open resources, including custom streams opened with Bash exec; the Bash file-descriptor guide shows how to close numeric and dynamically allocated FDs. Commands in an EXIT trap do not normally replace the status Bash was already exiting with; read $? at the start of the handler when you need to log or inspect that status, and call exit "$status" only when the handler must set the final status explicitly.
INT and TERM need explicit handlers if you want graceful shutdown or Ctrl+C cleanup; EXIT often runs afterward when the handler calls exit. SIGKILL and SIGSTOP cannot be caught. ERR traps follow errexit exception rules and are not a universal error handler — use set -E when the same ERR handler should inherit into functions and subshell environments, and expect a subshell failure to fire twice when errtrace is enabled.
For scripts that use set -euo pipefail, remember that ERR shares errexit's exception rules. Inspect live traps with trap -p and reset with trap - SIGNAL when a handler should only apply to part of the script.
References
- Bash manual — trap builtin
- Bash manual — Signals
- Bash manual — Error Traps
- signal(7) — Linux manual page
Frequently Asked Questions
Does EXIT run on Ctrl+C?
Often yes, when your INT handler ends with exit. Bash runs EXIT traps as part of shell shutdown, so a temp-dir cleanup registered on EXIT usually still runs after Ctrl+C. If the handler does not exit, the shell may keep running and EXIT is deferred. SIGKILL skips all traps.
Can Bash trap SIGKILL?
No. SIGKILL and SIGSTOP cannot be caught, blocked, or ignored. A trap line for KILL is silently ineffective for stopping the kill itself. Use cooperative shutdown with TERM and EXIT cleanup instead.
Why didn't ERR fire?
ERR only runs when a failing command would trigger errexit in that context. Failures in if tests, while conditions, &&/|| chains, and many pipeline contexts do not fire ERR. pipefail changes a pipeline's exit status; it does not enable ERR for every command in the pipe. Inside functions, enable set -E so the trap inherits — without it, a failure inside f followed by more commands can leave f returning success. ERR does not require set -e. Without set -e, running the ERR trap by itself does not make Bash exit; execution can continue after the trap returns.
What does set -E do?
set -E enables errtrace: the ERR trap inherits into functions and some subshell contexts. It does not turn on errexit by itself. Use it when you need a shared ERR handler for failures inside functions; pair with set -e when you also want immediate exit.
How do I access the original exit code in an EXIT trap?
$? at the start of the EXIT handler holds the status the shell had when it began exiting. Save it before running other commands if cleanup needs to log or inspect it. Commands in the trap do not normally replace that status; call exit "$status" when the handler must finish with a specific status explicitly.

