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