Bash trap: Signals, EXIT and Cleanup

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
Short on time?Beta Get the gist of this 15-minute read in seconds, then ask follow-up questions

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:

bash
trap -l | head -1

Sample output:

output
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP

The first line confirms SIGINT is signal 2 — what Ctrl+C sends in a typical terminal.

The basic forms are:

  • trap 'commands' SIGNAL — run commands when SIGNAL arrives
  • trap FUNCTION SIGNAL — call a shell function (preferred for non-trivial cleanup)
  • trap - SIGNAL — restore the default disposition
  • trap '' SIGNAL — ignore the signal until you reset it
  • trap -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
bash -c 'tmpdir=$(mktemp -d); trap "rm -rf \"$tmpdir\"" EXIT; touch "$tmpdir/marker"; ls "$tmpdir"'

Sample output:

output
marker

The 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
bash -c 'trap "true" EXIT; false'; echo parent:$?

Sample output:

output
parent:1

Likewise, a trap that fails does not turn a successful script into a failure:

bash
bash -c 'trap "false" EXIT; true'; echo parent:$?

Sample output:

output
parent:0

Ordinary cleanup commands such as rm behave the same way — they do not automatically overwrite the status Bash was already exiting with:

bash
bash -c 'trap "rm -rf /tmp/nonexistent 2>/dev/null" EXIT; false'; echo parent:$?

Sample output:

output
parent:1

When 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
bash -c 'cleanup(){ status=$?; echo "script exited with $status"; }; trap cleanup EXIT; false'; echo parent:$?

Sample output:

output
script exited with 1
parent:1

Save 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
bash -c 'trap "echo caught_INT; exit 130" INT; kill -INT $$' 2>&1; echo exit:$?

Sample output:

output
caught_INT
exit:130

Exit 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
bash -c 'trap "echo caught" INT; kill -INT $$; echo after'

Sample output:

output
caught
after

For 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
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:

output
f
removed
exit:130

The 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
bash -c 'trap "echo got_HUP; exit 129" HUP; kill -HUP $$' 2>&1; echo exit:$?

Sample output:

output
got_HUP
exit:129

For 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:

text
#!/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
bash /tmp/trap-term-demo.sh & pid=$!

Give the supervisor a moment to register its trap and start the child:

bash
sleep 0.5

Signal the Bash supervisor process — not a wrapper such as timeout:

bash
kill -TERM "$pid"

Wait for the supervisor to finish and read its exit status:

bash
wait "$pid"; echo "parent exit: $?"

Sample output:

output
TERM caught
parent exit: 143

The 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
bash -c 'h(){ echo signal=$1; }; trap "h TERM" TERM; trap "h INT" INT; kill -INT $$' 2>&1

Sample output:

output
signal=INT

Multiple 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
bash -c 'cleanup(){ echo cleaning; }; trap cleanup TERM; echo started; kill -TERM $$; echo still_running'

Sample output:

output
started
cleaning
still_running

For shutdown handlers, call exit with the conventional status for each signal. Keep file cleanup on EXIT and exit explicitly from signal handlers:

text
trap cleanup EXIT
trap 'on_signal 130' INT
trap 'on_signal 143' TERM

ERR 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
bash -c 'on_err(){ echo ERR_fired; }; trap on_err ERR; f(){ false; echo after; }; f; echo done'

Sample output:

output
after
done

Without 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
bash -c 'trap "echo ERR" ERR; f(){ false; }; f; echo done'

Sample output:

output
ERR
done

Enable 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
bash -c 'set -E; on_err(){ echo ERR_fired; }; trap on_err ERR; f(){ false; echo after; }; f; echo done'

Sample output:

output
ERR_fired
after
done

With 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
bash -c 'trap "echo ERR_sub" ERR; ( false ); echo after_sub'

Sample output:

output
ERR_sub
after_sub

That 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
bash -c 'set -E; trap "echo ERR_sub" ERR; ( false ); echo after_sub'

Sample output:

output
ERR_sub
ERR_sub
after_sub

The 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
bash -c 'set -E; trap "echo ERR" ERR; out=$(false); echo out=$out'

Sample output:

output
ERR
out=ERR

Here 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
bash -c 'trap "echo hi" INT; trap -p INT'

Sample output:

output
trap -- 'echo hi' SIGINT

Restore the default handler with trap - SIGNAL:

bash
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
bash -c 'tmpdir=$(mktemp -d); trap "rm -rf $tmpdir" EXIT; echo trap_line=$(trap -p EXIT); touch "$tmpdir/f"'

Sample output:

output
trap_line=trap -- 'rm -rf /tmp/tmp.UFj69NXYgt' EXIT

Here 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
bash -c 'tmpdir=$(mktemp -d); cleanup(){ rm -rf "$tmpdir"; echo cleaned; }; trap cleanup EXIT; touch "$tmpdir/f"; ls "$tmpdir"'

Sample output:

output
f
cleaned

Function 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.

bash
#!/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"
}

work

Register 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


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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)