--- title: Using trap for cleanup in shell scripts date: 2026-07-13 tags: [linux] description: "trap … EXIT does not always trigger if the script is terminated by a signal" --- It is great when programming languages offer robust ways to cleanup resources. Python has [context managers](https://peps.python.org/pep-0343/), rust has the [drop trait](https://doc.rust-lang.org/std/ops/trait.Drop.html), go has [defer](https://go.dev/blog/defer-panic-and-recover), and shell scripts have `trap`: ```sh tmpdir="$(mktemp -d)" trap 'rm -rf -- "$tmpdir"' EXIT ``` However, 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](https://stackoverflow.com/questions/4042201). 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](https://github.com/koalaman/shellcheck/issues/3287). For these reasons, it is quite common to move the actual cleanup code to a function: ```sh on_sigterm() { echo "$foo" } trap on_sigterm TERM ``` ## trap on EXIT Apart from registering signals, `trap` has one special condition: EXIT. [POSIX](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html#trap) 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](./test.py)) to see how it behaves in practice: - All tested shells will execute the EXIT handler on normal completion, explicit `exit`, and errors with `set -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`: ```sh trap cleanup EXIT trap 'exit 130' INT TERM ``` However, this fails to [communicate the signal to the parent process](https://www.cons.org/cracauer/sigint.html). 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: ```sh trap cleanup EXIT trap 'trap - INT TERM EXIT; cleanup; kill -INT $$' INT TERM ``` ## Multi-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: ```sh 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: ```sh 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.