trap - Handling Signals and Cleanup in Bash

trap - Handling Signals and Cleanup in Bash

What You'll Learn

  • How to use trap to catch signals and guarantee cleanup runs
  • How to prevent the classic bug where Ctrl-C leaves temp files behind
  • A production-ready template combining the EXIT pseudo-signal, a cleanup function, and set -e

Quick Summary

  • Funnel all cleanup into a single trap cleanup EXIT (it runs no matter how the script ends)
  • Create temp files with mktemp, then set the trap immediately
  • SIGKILL (9) and SIGSTOP cannot be trapped. Handle everything else.

Prerequisites

  • Shell: bash (EXIT / INT / TERM also work in POSIX sh)
  • Audience: intermediate users writing shell scripts

What is trap?

Conclusion: trap is a bash builtin that registers a command to run when a signal or special event occurs. It is how you schedule cleanup.

trap registers an interrupt handler: "when this signal arrives, run this command." The basic syntax is:

trap 'command' SIGNAL...

For example, catching Ctrl-C (SIGINT) to print a message:

trap 'echo "Interrupted"' INT

A signal can be named SIGINT, INT, or given by number 2. The SIG prefix is optional, and for portability the bare name (INT / TERM / EXIT) is preferred.

The first argument is a string the shell evaluates. Single quotes defer expansion until the signal actually arrives. Double quotes expand variables when the trap is set, so be deliberate about quoting when embedding cleanup variables.

Why use the EXIT pseudo-signal?

Conclusion: EXIT is a pseudo-signal that fires the instant a script terminates, on success, error, or Ctrl-C. Funneling cleanup here is the most robust approach.

Trapping real signals (INT / TERM) one by one risks missing one and skipping cleanup. EXIT reacts to the "script is ending" event rather than a signal, so it runs exactly once regardless of how the script exits.

#!/usr/bin/env bash
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"' EXIT

# Whatever happens next (normal exit, set -e failure, Ctrl-C),
# tmpfile is always removed when the script ends.
echo "working..." > "$tmpfile"
cat "$tmpfile"

The EXIT trap does not run on SIGKILL (kill -9). SIGKILL terminates the process immediately, leaving no chance to run a handler. Understand this as a hard limit of trap.

How do you write a cleanup function?

Conclusion: Collect cleanup into a cleanup function and register it with trap cleanup EXIT. Even with many temporary resources, you manage teardown in one place.

An inline command string is fine for short work, but readability suffers as the list of things to remove grows. Extracting a function is the standard practice.

#!/usr/bin/env bash
set -euo pipefail

workdir=$(mktemp -d)
lockfile="/tmp/myjob.lock"

cleanup() {
  local rc=$?          # save the preceding exit code
  rm -rf "$workdir"
  rm -f "$lockfile"
  echo "cleanup done (exit=$rc)"
  exit "$rc"           # exit with the original code
}
trap cleanup EXIT

# --- main work ---
touch "$lockfile"
echo "data" > "$workdir/output.txt"

The key line is local rc=$? at the top of the function. Running other commands inside cleanup overwrites $?, so you save the exit code first and restore it with exit "$rc". This means "clean up, but still report the real error to the caller."

Creating a working directory with mktemp -d and removing it with rm -rf "$workdir" is safer than tracking individual temp files. An empty workdir would make rm -rf dangerous, so set -u (error on undefined variables) prevents that footgun.

How do you handle multiple signals?

Conclusion: Funnel cleanup into EXIT, and only trap INT / TERM individually when you need custom interrupt behavior. Some signals cannot be caught.

Here are the main signals and their uses.

Signal Number Triggered by Catchable
INT 2 Ctrl-C Yes
TERM 15 kill default Yes
HUP 1 Terminal disconnect Yes
QUIT 3 Ctrl-\ Yes
EXIT 0 Script ends (pseudo) Yes
KILL 9 kill -9 No
STOP 19 Process suspend No

Print a custom message on INT while leaving teardown to EXIT:

#!/usr/bin/env bash
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"' EXIT
trap 'echo "Interrupted by user"; exit 130' INT

echo "Running... press Ctrl-C to interrupt"
sleep 30

When the INT handler ends the script with exit 130, the EXIT trap then fires and removes tmpfile. By convention, the exit code for INT is 128 + 2 = 130.

SIGKILL (9) and SIGSTOP (19) cannot be caught, ignored, or redefined by OS design. The requirement "clean up even on kill -9" is impossible with trap. Handle it from the outside, e.g. a supervising process or systemd's ExecStopPost=.

How do you combine it with set -e safely?

Conclusion: set -e (exit on error) pairs well with trap. Even if a command fails midway, the trap runs cleanup, leaving no half-finished state.

With set -e, the script exits the moment a command fails. The EXIT trap still runs, so cleanup is guaranteed. Adding the ERR pseudo-signal (a bash extension) lets you record which command failed.

#!/usr/bin/env bash
set -euo pipefail

workdir=$(mktemp -d)
trap 'rm -rf "$workdir"' EXIT
trap 'echo "error: failed at line $LINENO (exit=$?)" >&2' ERR

cp /etc/hostname "$workdir/"
grep "nonexistent string" "$workdir/hostname"   # fails here -> ERR and EXIT fire
echo "never reached"

The ERR trap reports the failure and the EXIT trap cleans up — a two-stage pattern that is the foundation of a defensive script.

Adding set -o pipefail also detects failures partway through a pipeline. set -euo pipefail + trap cleanup EXIT is worth memorizing as the hardening template for shell scripts.

How do you inspect or remove a trap?

Conclusion: List active traps with trap -p, and remove one with trap - SIGNAL. Both are handy when debugging.

List the traps currently set with trap -p.

$ trap 'rm -f /tmp/x' EXIT
$ trap -p
trap -- 'rm -f /tmp/x' EXIT

To remove a trap for a specific signal, set its command to -.

trap - EXIT       # remove the EXIT trap (restore default behavior)

To ignore a signal (do nothing when it arrives), set the command to an empty string.

trap '' INT       # disable Ctrl-C

Setting trap '' INT to ignore is inherited by child processes. If you only want to disable Ctrl-C in part of a script, restore it with trap - INT once you leave the critical section.

Summary: the safe trap template

Conclusion: Create temp resources with mktemp, set trap cleanup EXIT immediately, and have cleanup save the exit code before tearing down. This is the pattern that does not fail.

Copy-paste: skeleton of a robust script

#!/usr/bin/env bash
set -euo pipefail

workdir=$(mktemp -d)

cleanup() {
  local rc=$?
  rm -rf "$workdir"
  exit "$rc"
}
trap cleanup EXIT
trap 'echo "interrupted" >&2; exit 130' INT

# --- main work goes here ---
echo "scratch" > "$workdir/tmp.txt"

Three points to remember:

  • Funnel cleanup into EXIT (it runs on success, failure, and interrupt)
  • Save the exit code at the top of cleanup with local rc=$?, then exit "$rc"
  • SIGKILL / SIGSTOP cannot be trapped. Design with that in mind.

As a next step, try writing a practical trap-based script (lock-file management, a scheduled job) alongside getopts Basics or Cron Basics to deepen your understanding.

Next Reading