Bash scripting guide · part 20 of 32
Traps and signals
Running cleanup code with trap on EXIT, handling INT and TERM, the ERR pseudo-signal, and the timing and scoping rules that trip people up.
trap 'commands' CONDITION... registers code for the shell to run when something happens.
The conditions are real signals (INT, TERM, HUP, ...) plus four bash pseudo-signals:
EXIT (the shell is about to exit, for any reason it can handle), ERR (a command failed, under
the same rules as set -e), DEBUG (before every command) and RETURN (a function or sourced
file finished). Almost every script that creates something temporary needs exactly one of
these: an EXIT trap.
Cleanup on EXIT
An EXIT trap runs however the script ends
#!/usr/bin/env bash
tmp=$(mktemp)
trap 'rm -f "$tmp"; echo "cleaned up $tmp"' EXIT
echo "working in $tmp"
ls data > "$tmp"
wc -l < "$tmp"
exit 3
bash job.sh; echo "status $?"
working in /tmp/tmp.8Qo1AtQHJd 11 cleaned up /tmp/tmp.8Qo1AtQHJd status 3
The trap fires on exit 3 and removes the temporary file. The script's status is still 3:
an EXIT trap does not change the exit status unless it calls exit itself.
Note the quoting: the trap string is in single quotes, so $tmp is expanded when the trap
runs, not when it is set. With double quotes the value is baked in at trap time, which also
works here but breaks when the path contains a quote or when the variable changes later.
The EXIT trap also runs when set -e stops the script and when a handled signal leads to
exit, which is why it is the one place to put cleanup.
The EXIT trap runs when set -e aborts
#!/usr/bin/env bash
set -e
trap 'rc=$?; echo "exiting with status $rc"' EXIT
echo "step 1"
grep -q PANIC logs/app.log
echo "step 2"
bash build.sh
step 1 exiting with status 1
grep finds no match and returns 1, -e ends the script, and the trap runs first. Inside an
EXIT trap, $? is the status the shell is exiting with; save it in a variable on the first
line before any other command overwrites it.
Handling signals
When a process receives TERM or INT (Ctrl-C) and has no handler for it, it dies with status
128 + the signal number. Bash still runs its EXIT trap on the way out for these fatal signals.
Not every shell does: dash, which is /bin/sh on Debian and Ubuntu, skips the EXIT trap when a
signal kills it, so portable scripts trap the signals too.
Bash runs the EXIT trap when TERM kills the script
#!/usr/bin/env bash
trap 'echo "EXIT trap ran"' EXIT
kill -TERM $$
echo "not reached"
bash worker.sh; echo "status $?"
EXIT trap ran Terminated status 143
No TERM handler is set, so the signal is fatal: the script never reaches its last line and its
status is 143 (128 + 15). The EXIT trap ran anyway. The calling shell prints Terminated
because its child was killed by a signal.
To do something specific on a signal, or to choose the exit status yourself, trap it and call
exit from the handler. exit then runs the EXIT trap, so the cleanup stays in one place.
Turn TERM into an orderly exit
#!/usr/bin/env bash
trap 'echo "EXIT trap: cleaning up"' EXIT
trap 'echo "got TERM"; exit 143' TERM
echo "pid $$ running"
kill -TERM $$
echo "not reached"
bash server.sh
pid 6209 running got TERM EXIT trap: cleaning up
The script signals itself. The TERM handler runs, calls exit 143, and that triggers the
EXIT trap. 143 (128 + 15) is the conventional status for "terminated", so callers see the
same code they would without the handler.
A trap waits for the foreground command
Bash runs a trap only between commands. If the script is in the middle of a long foreground
command such as sleep 3 or rsync, a signal sent to the script is remembered and the trap
runs after that command returns. To react promptly, run the long command in the background and
wait for it: wait is interrupted by a trapped signal.
Foreground commands delay the trap; wait does not
#!/usr/bin/env bash
trap 'echo "trap ran after ${SECONDS}s"' USR1
(sleep 1; kill -USR1 $$) &
sleep 3
echo "foreground sleep done at ${SECONDS}s"
(sleep 1; kill -USR1 $$) &
sleep 3 & wait $!
echo "wait returned $? at ${SECONDS}s"
bash slow.sh
trap ran after 3s foreground sleep done at 3s trap ran after 4s wait returned 138 at 4s
The first signal arrives after one second, but the trap only runs once the foreground sleep 3
has finished. The second time, wait is interrupted as soon as the signal arrives, the trap runs
right away, and wait returns 138 (128 + 10, the number of USR1).
The ERR pseudo-signal
trap ... ERR runs whenever a command fails, with the same exceptions as set -e: not in
if conditions, &&/|| lists or after !. $LINENO and $BASH_COMMAND inside it tell you
where. By default an ERR trap is not inherited by functions, command substitutions or
subshells; set -E (errtrace) makes it so.
Report failures, including inside functions, with set -E
#!/usr/bin/env bash
trap 'echo "ERR at line $LINENO: $BASH_COMMAND (status $?)"' ERR
load() { cat data/missing.json; }
load
echo "--- with set -E"
set -E
load
bash err.sh
cat: data/missing.json: No such file or directory ERR at line 4: cat data/missing.json (status 1) --- with set -E cat: data/missing.json: No such file or directory ERR at line 3: cat data/missing.json (status 1) ERR at line 7: cat data/missing.json (status 1)
Without -E, the trap fires once, after load returns, at line 4 where the function was
called; BASH_COMMAND still names the cat that failed last. With set -E it also fires
inside the function at line 3, and then again for the call on line 7. The script's status is
the failed call's, 1.
One trap per condition
Each condition has a single handler. A second trap ... EXIT replaces the first; it does not
add to it. That bites when a library function sets its own cleanup trap. Keep one cleanup
function and have it handle everything, or append deliberately. trap -p shows what is set,
trap - SIG restores the default, and trap '' SIG ignores the signal.
A second trap replaces the first
trap 'echo "remove lock"' EXIT
trap 'echo "remove temp dir"' EXIT
trap -p EXIT
trap -- 'echo "remove temp dir"' EXIT remove temp dir
trap -p shows only the second handler; the lock would never be removed. When the shell exits
it runs that one handler and prints remove temp dir.
One cleanup function that grows as the script goes
#!/usr/bin/env bash
cleanup_items=()
cleanup() {
for item in "${cleanup_items[@]}"; do
rm -rf -- "$item" && echo "removed $item"
done
}
trap cleanup EXIT
dir=$(mktemp -d)
cleanup_items+=("$dir")
lock=$(mktemp /tmp/job.XXXX.lock)
cleanup_items+=("$lock")
echo "using $dir and $lock"
bash multi.sh
using /tmp/tmp.czbaR0fgRm and /tmp/job.Qsmo.lock removed /tmp/tmp.czbaR0fgRm removed /tmp/job.Qsmo.lock
The trap is a function call, so it can read state built up later. Each resource is added to the list as soon as it exists, so a failure halfway through only cleans up what was created.
Ignoring a signal
trap '' SIGNAL ignores it, and unlike a handler, an ignored signal stays ignored in child
processes. trap - SIGNAL undoes either. nohup works on the same principle for HUP.
An ignored signal is ignored by children too
trap '' INT
bash -c 'kill -INT $$; echo "child survived SIGINT"'
trap - INT
bash -c 'kill -INT $$; echo "not printed"'
echo "second child status: $?"
child survived SIGINT second child status: 130
With INT ignored in the parent, the child inherits the ignored disposition and kill -INT
has no effect on it. After trap - INT restores the default, the second child dies from its own
signal, status 130 (128 + 2).
Traps in subshells
Handlers are reset to the default in subshells, including ( ... ), pipelines and $(...).
An EXIT trap set in the main script does not run when a subshell exits, and a trap you set
inside a subshell only lasts for that subshell. Ignored signals are the exception, as shown
above. Bash does let $(trap -p) see the parent's handlers, so you can save and restore them.
Checklist
- One
EXITtrap per script, set right after the first resource is created, calling acleanupfunction. - Single-quote the trap string, or call a function.
- Save
$?on the first line of the handler if you need it. - Trap
INTandTERMonly if you need to do something special; theEXITtrap plusexitfrom those handlers covers cleanup. - Long waits: background the command and
wait, so signals are handled promptly.
Related
trapRun a command when the shell receives a signal or exits, typically to clean up temporary files.killSend a signal to processes by PID or job number; by default TERM, which asks a process to exit.mktempCreate a temporary file or directory with a unique, unpredictable name and print that name.waitWait for background jobs of the current shell to finish, and return their exit status.nohupRun a command immune to the hangup signal, so it keeps running after you log out or close the terminal.- Strict mode and its caveatsWhat set -euo pipefail actually does, the cases where -e silently does nothing, the traps in -u and pipefail, and a balanced way to use it.
- Exit codesWhat exit statuses mean, how $? works, the reserved values 126, 127 and 128+N, PIPESTATUS, and how to choose codes for your own scripts.
- Debugging bash scriptsCheck syntax with bash -n, trace with set -x, add file and line numbers through PS4, send the trace elsewhere with BASH_XTRACEFD, and report failures with trap ERR.
- Bash idioms for real scriptsThe small patterns that make scripts robust - die(), logging to stderr, ${1:?usage}, command -v checks, mktemp with trap cleanup, retries with backoff, locking and safe config parsing.
- Subshells and command groupingThe difference between ( ... ) and { ...; }, what a subshell inherits and what it cannot change, which constructs create one, and what that costs.