Why this matters
Real scripts create things they must undo: temporary files, lock files, half-written output, a mounted image, a background helper process. If the script is interrupted with Ctrl-C, killed by a timeout, or stopped by set -e halfway through, that mess stays behind: /tmp fills up, a stale lock file blocks every later run, a half-written report.csv gets uploaded as if it were complete. The trap builtin lets you say "whatever happens, run this on the way out".
trap in one minute
trap 'commands' CONDITION...
The commands are stored as a string and run when one of the conditions occurs. Conditions are signal names (INT, TERM, HUP, ...) or one of bash's pseudo-signals:
| Condition | When it fires |
|---|---|
EXIT | when the shell exits, for any reason it controls (end of script, exit, errexit) |
ERR | after any command fails in a place where set -e would exit (same exceptions) |
INT | Ctrl-C was pressed (SIGINT) |
TERM | polite termination request (SIGTERM), the default of kill |
HUP | the terminal went away (SIGHUP) |
$ cat t.sh
trap 'echo "goodbye from $$"' EXIT
echo "working"
exit 4
$ bash t.sh; echo "status $?"
working
goodbye from 2812
status 4
The trap ran, and the script's exit status was still 4. (Inside an EXIT trap $? holds the status the script is exiting with; the trap does not change it unless it calls exit itself.)
Other forms:
trap - EXIT # remove the trap (restore default behavior)
trap '' INT # ignore Ctrl-C entirely
trap -p # print the traps currently set
trap -l # list signal names and numbers
The temporary file pattern
Never invent temporary names like /tmp/myscript.tmp: two copies of the script running at once would clobber each other, and a predictable name in a world-writable directory is a classic security hole (another user creates it first as a symlink to one of your files). mktemp asks for a name that did not exist, creates the file atomically with permissions only you can read, and prints the name:
$ mktemp
/tmp/tmp.k3VqzX01aB
$ mktemp -d /tmp/report.XXXXXX
/tmp/report.Q9fT2c
-d makes a directory; the Xs in a template are replaced with random characters. Combine it with a trap, installed immediately after creation:
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
sort -u "$1" > "$tmp/sorted"
# ... any failure from here on still removes $tmp
If you put the trap later, a failure between mktemp and trap leaks the directory. If you put it earlier, $tmp is unset when the trap fires (an error under set -u).
Quoting the trap string
The trap command is a string that bash evaluates when the trap fires, so the quoting rules from module 7 apply twice: once now, once later.
trap "rm -rf $tmp" EXIT # $tmp expanded NOW; breaks if the path contains spaces
trap 'rm -rf "$tmp"' EXIT # stored as-is; $tmp expanded later, quoted correctly
The second form is almost always right. Use the first only when you deliberately want to freeze the current value.
A cleaner style for anything longer than one command is a function:
cleanup() {
rm -rf "$tmp"
[ -n "${helper_pid:-}" ] && kill "$helper_pid" 2>/dev/null
}
trap cleanup EXIT
Signals: what happens underneath
A signal is a small asynchronous notification the kernel delivers to a process: "the user pressed Ctrl-C", "someone asked you to terminate", "the pipe you write to has no reader". Each signal has a default action, usually "terminate the process". When you press Ctrl-C, the terminal driver in the kernel sends SIGINT to every process in the foreground process group (module 13 explains groups). A process can tell the kernel "call my handler instead" for most signals; that is exactly what bash does when you trap ... INT.
Two details matter for scripts:
- bash runs a trap between commands. If the script is waiting for a foreground program (say
sleep 60), the signal is noted and the handler runs only after that program finishes. That program usually got the same Ctrl-C and died, so in practice the handler runs promptly. For a SIGTERM sent only to the script's PID, though, a longsleepdelays the handler. - An untrapped terminating signal ends the script. Current bash runs the EXIT trap first (you can verify it in the sandbox), but
sh/dash and older bash versions do not, and an explicit handler documents your intent. To be sure, convert signals into a normal exit:
trap cleanup EXIT
trap 'exit 130' INT # 128 + 2 (SIGINT)
trap 'exit 143' TERM # 128 + 15 (SIGTERM)
trap 'exit 129' HUP # 128 + 1 (SIGHUP)
Now Ctrl-C causes exit 130, which fires the EXIT trap, which cleans up, and the caller still sees the conventional "killed by SIGINT" status. SIGKILL (kill -9) and SIGSTOP cannot be trapped at all: the kernel acts without involving the process.
ERR: report where it failed
The ERR trap fires on the same failures that set -e reacts to. Combined with $LINENO and $BASH_COMMAND, it gives a useful last message:
set -euo pipefail
trap 'echo "error: line $LINENO: $BASH_COMMAND (status $?)" >&2' ERR
cp config.yml /etc/app/
cp: cannot create regular file '/etc/app/config.yml': Permission denied
error: line 3: cp config.yml /etc/app/ (status 1)
By default the ERR trap is not inherited by functions, command substitutions and subshells; set -E (errtrace) changes that. Use it if your ERR trap seems to "miss" failures inside functions.
Writing output atomically
Cleanup has a sibling: never let a reader see a half-written file. Write to a temporary file in the same directory and rename it into place at the end:
out=report.csv
tmpout=$(mktemp "$out.XXXXXX")
trap 'rm -f "$tmpout"' EXIT
generate_report > "$tmpout"
mv "$tmpout" "$out" # rename is atomic within one filesystem
mv within a filesystem is a single rename system call: readers see either the old complete file or the new complete file, never a mixture. (After the mv, the EXIT trap's rm -f finds nothing to remove, which is fine.)
Common mistakes
- Trap installed too late. Anything that fails between
mktempandtrapleaks. Put them on adjacent lines. - Double quotes in the trap string. The variable is frozen at the wrong time, and spaces in paths break the later
rm. - Overwriting a trap.
trap cleanup1 EXITthentrap cleanup2 EXITkeeps only the second. Use one cleanup function that does everything. rm -rf $tmpwith$tmpempty. Ifmktempfailed and the script did not stop, an unquoted empty variable makes the commandrm -rfwith no operand (harmless) butrm -rf "$tmp"/*becomesrm -rf /*. Underset -eu, a failedmktempstops the script before that.- Expecting cleanup after
kill -9. Impossible by design. Make your script tolerate stale leftovers on the next run (for example, check whether a lock file's PID is still alive).
Exercises
Two quizzes on trap semantics and quoting, then a script that must clean up on both its success and its error path. The checker runs your script several times in the same container and then looks in /tmp for leftovers.
Recap
trap 'cmds' EXITruns cleanup whenever the script exits by its own decision.- Create temporary files and directories with
mktemp; install the trap on the next line. - Single-quote the trap string so variables expand when it fires.
- Turn INT/TERM/HUP into
exit 128+Nso the EXIT trap runs; KILL and STOP cannot be caught. trap ... ERRwith$LINENOand$BASH_COMMANDreports where a strict script failed.- Write to a temporary file and
mvit into place for atomic output.
Going further: help trap, and trap -l for the signal list. In the sandbox shell, run bash -c 'trap "echo caught" INT; sleep 5; echo after', press Ctrl-C during the sleep, and work out the order of what you see.