Published on 2026-07-13 linux
It is great when programming languages offer robust ways to cleanup resources. Python has context managers, rust has the drop trait, go has defer, and shell scripts have trap:
tmpdir="$(mktemp -d)"
trap 'rm -rf -- "$tmpdir"' EXITHowever, I recently realized that this fails if the process is terminated by Ctrl-C. Let's look into that.
trap for signal handlers
Before we look into trap … EXIT, let's first look into its other use case: registering signal handlers. As a quick refresher, signals on UNIX can interrupt a process at any time. For some signals (e.g. SIGINT), processes can register handlers that replace the default behavior. SIGKILL and SIGSTOP cannot be caught.
In C, you would register a signal handler using the signal() system call. In a shell script, you use trap instead. For example, trap 'echo foo' TERM will output "foo" whenever the process receives a SIGTERM. Since this handler replaces the default one, the process will not terminate.
There are quite a few signals that are supposed to terminate a process: SIGHUP, SIGINT, SIGQUIT, SIGKILL, and SIGTERM. The differences between them are not completely obvious. For this article, I will concentrate on SIGINT and SIGTERM, because they are the most common ones. SIGINT is the one that is sent when you press Ctrl-C.
trap's action argument is given as a string, which makes quoting a bit awkward. For example trap "echo '$foo'" TERM and trap 'echo "$foo"' TERM are different because one expands the variable on definition, while the other expands the variable on execution. This also tends to confuse linters like shellcheck. For these reasons, it is quite common to move the actual cleanup code to a function:
on_sigterm() {
echo "$foo"
}
trap on_sigterm TERMtrap on EXIT
Apart from registering signals, trap has one special condition: EXIT. POSIX has this to say:
The EXIT condition shall occur when the shell terminates normally (exits), and may occur when the shell terminates abnormally as a result of delivery of a signal (other than SIGKILL) whose trap action is the default.
I tested 4 different shells (bash, dash, zsh, busybox) (script) to see how it behaves in practice:
- All tested shells will execute the EXIT handler on normal completion, explicit
exit, and errors withset -e. - No shell executes the EXIT handler on SIGKILL (expected because SIGKILL cannot be caught).
- Only bash executes the EXIT handler on SIGINT and SIGTERM.
- The exit code is not affected by cleanup (unless the cleanup code exits explicitly)
Bash is closest to what I expect: EXIT should mean every exit, including signals. So how can we get that behavior in all shells?
A simple option is to convert the relevant signals to explicit exit:
trap cleanup EXIT
trap 'exit 130' INT TERMHowever, this fails to communicate the signal to the parent process. The proper solution is to remove the handler, call that cleanup code, and then re-raises the signal. To avoid executing the cleanup code twice in bash we also need to reset the EXIT handler:
trap cleanup EXIT
trap 'trap - INT TERM EXIT; cleanup; kill -INT $$' INT TERMMulti-Stage Cleanup
Cleanup gets interesting once we have multiple cleanup steps in a single script. Consider for example a script that needs to mount multiple locations in a specific order, and unmount them in reverse order during cleanup. If any of these steps fail, we need to unmount only the locations that have already been mounted.
One approach I have used before (but don't particularly like it because of the use of eval) is to have a mutable cleanup expression:
cleanup='true'
trap 'eval "$cleanup"' EXIT
trap 'trap - EXIT INT TERM; eval "$cleanup"; kill -INT $$' INT TERM
defer() {
cleanup="$1; $cleanup"
}A flexible option that works consistently across the shells that I tested is using subshells:
echo 'enter script'
trap "echo 'exit script'" EXIT
(
echo 'enter subshell'
trap "echo 'exit subshell'" EXIT
)Cleanup in functions is less consistent: bash has the special RETURN condition, while zsh reuses EXIT for that case. Other shells do not seem to support traps on the function level, so I didn't look into it any further.
Conclusion
trap is a very useful tool for doing cleanup in shell scripts. Unfortunately, it does not provide a universal “run on everything” hook.
Cleanup on signals such as SIGINT and SIGTERM is important. Bash handles these cases as I would expect, but I do not want to restrict myself to a single shell. Implementing the correct behavior in a compatible way is much harder than it should be.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.