Bash & the POSIX Shell Module 9 – Robust scripts: errors, traps and debugging
Traps and cleanup: EXIT, ERR and signals
Register cleanup code with trap, see when an EXIT trap runs and when it cannot, handle INT and TERM gracefully, and tell ERR, RETURN and DEBUG apart.
What you will learn
- Install cleanup code that runs however a Bash script exits, and name the one signal it misses
- Handle INT and TERM so a script finishes its current item and reports 130 or 143
- Use ERR, RETURN and DEBUG traps for diagnostics, and know which need set -E or set -T
- Write trap actions in single quotes so variables expand at trap time, not at trap-set time
Before you start
What you’ll learn · Before you start
What you will learn
- Install cleanup code that runs however a Bash script exits, and name the one signal it misses
- Handle INT and TERM so a script finishes its current item and reports 130 or 143
- Use ERR, RETURN and DEBUG traps for diagnostics, and know which need set -E or set -T
- Write trap actions in single quotes so variables expand at trap time, not at trap-set time
Before you start
On this page
A batch job wrote its progress to ./workspace, crashed on a bad line of input, and left the folder behind. The next
run opened the folder with yesterday’s half-finished files inside and treated them as its own, double-counting
every bill. The missing line was one: a cleanup trap that runs however the script ends.
You will register code to run on exit with the trap builtin, learn the one way out that still skips it, catch
SIGINT and SIGTERM so a worker finishes its current item and reports the right status, use ERR, RETURN and
DEBUG for diagnostics, and single-quote the trap action so variables expand when the trap runs.
The trap builtin registers code to run on a signal
The trap: A Bash builtin that registers code to run on a signal or a shell event: EXIT when the shell exits, ERR when a command's failure would end a set -e script, RETURN when a function returns, DEBUG before every simple command. trap action signal …; the action is one shell word. builtin says “when this signal reaches the shell, run that code”. Its two
arguments are the action, as one shell word, and one or more signal names (or EXIT, the shell’s own event).
trap is one of POSIX’s special built-ins, so every POSIX shell has it, and the Bash manual describes it with the
Bourne shell builtins.
The usual way to pair it with a temporary folder is mktemp -d and a cleanup action:
#!/bin/bash
# Pair mktemp -d with an EXIT trap so the folder is removed however the shell ends.
# Read-only snippet; a real script runs this at the top.
work=$(mktemp -d)
trap 'rm -rf -- "$work"' EXIT The lesson’s examples use a fixed folder name so the recorded output is identical from run to run; a real script
writes work=$(mktemp -d) and the trap removes it. tmp_cleanup_session.py writes three small scripts and runs
them, then runs tmp_term.sh in Bash and in dash (the next section comes back to those two runs):
from session import run, write
# An EXIT trap paired with a temp folder is the usual way to make sure the folder is removed however the shell ends:
# a normal exit, a false under set -e, or a Ctrl-C from outside. The lesson's examples use a fixed name so the
# output is identical across runs; a real script writes "work=$(mktemp -d)" instead.
write("tmp_normal.sh", """#!/bin/bash
set -euo pipefail
work=./workspace
mkdir -p "$work"
trap 'rc=$?; rm -rf -- "$work"; echo "cleaned $work (rc=$rc)"' EXIT
touch -- "$work/report.json"
ls -- "$work"
echo "work done"
""")
write("tmp_errors.sh", """#!/bin/bash
set -euo pipefail
work=./workspace
mkdir -p "$work"
trap 'rc=$?; rm -rf -- "$work"; echo "cleaned $work (rc=$rc)"' EXIT
touch -- "$work/report.json"
echo "about to fail"
false
echo "unreachable"
""")
write("tmp_quoted_wrong.sh", """#!/bin/bash
# The classic double-quote slip: $work was empty when the trap was set, so the stored action is
# rm -rf -- ; echo "cleaned []" and rm gets no operand at all. Single-quote the action so the expansion happens
# when the trap runs, not when it is set.
work=
trap "rm -rf -- $work; echo \\"cleaned [$work]\\"" EXIT
work=./workspace
mkdir -p "$work"
touch -- "$work/leftover"
echo "after the script, the folder named $work still exists"
""")
# A TERM from outside, simulated by the script sending it to itself: the script sets no TERM trap at all.
write("tmp_term.sh", """#!/bin/bash
work=./workspace
mkdir -p "$work"
trap 'rm -rf -- "$work"; echo "cleaned $work"' EXIT
echo "working when TERM arrives"
kill -TERM $$
echo "unreachable"
""")
run("""
chmod +x tmp_normal.sh tmp_errors.sh tmp_quoted_wrong.sh tmp_term.sh
bash tmp_normal.sh
!bash tmp_errors.sh
bash tmp_quoted_wrong.sh
echo "leftover check: $(ls workspace/ 2>/dev/null)"
rm -rf ./workspace
!bash tmp_term.sh
echo "after bash: $(ls -d workspace 2>/dev/null || echo 'no workspace left')"
!dash tmp_term.sh
echo "after dash: $(ls -d workspace 2>/dev/null || echo 'no workspace left')"
rm -rf ./workspace
""") Output
$ chmod +x tmp_normal.sh tmp_errors.sh tmp_quoted_wrong.sh tmp_term.sh $ bash tmp_normal.sh report.json work done cleaned ./workspace (rc=0) $ bash tmp_errors.sh about to fail cleaned ./workspace (rc=1) [exit status 1] $ bash tmp_quoted_wrong.sh after the script, the folder named ./workspace still exists cleaned [] $ echo "leftover check: $(ls workspace/ 2>/dev/null)" leftover check: leftover $ rm -rf ./workspace $ bash tmp_term.sh working when TERM arrives cleaned ./workspace [exit status 143] $ echo "after bash: $(ls -d workspace 2>/dev/null || echo 'no workspace left')" after bash: no workspace left $ dash tmp_term.sh working when TERM arrives [exit status 143] $ echo "after dash: $(ls -d workspace 2>/dev/null || echo 'no workspace left')" after dash: workspace $ rm -rf ./workspace
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tmp_cleanup_session.py
tmp_normal.sh finished its last line, the EXIT trap ran, printed cleaned ./workspace (rc=0), and the shell exited
0. tmp_errors.sh hit false under set -e, the EXIT trap still ran with rc=1 from the saved exit status, and
the folder was gone before the shell left. The third one is a slip: with the action in double quotes, the $work
inside expanded when the trap was set, while it was still empty, so the stored action was
rm -rf -- ; echo "cleaned []", an rm with no operand at all. After the script the folder was still there, as the
leftover check line showed. The section after next is about exactly that.
Checkpoint
Check: The trap builtin and its two arguments
An EXIT trap runs however the script ends, except on SIGKILL
The EXIT pseudo-signal is the useful one. The Bash manual says the EXIT trap is executed when the shell exits, and in Bash 5.3 that means every way out but one. Step through five ways a script can end:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The trap is set. trap cleanup EXIT tells Bash to run cleanup when this shell exits. Setting it costs nothing; what matters is which ways out count as exits.
- Runs to the end. The usual case: the shell finishes its last command, runs the EXIT trap, and leaves with the status it had before the trap. tmp_normal.sh printed "cleaned ./workspace (rc=0)".
- set -e stops it. An unhandled non-zero ends a set -e script, and the EXIT trap still runs on the way out. tmp_errors.sh printed "cleaned ./workspace (rc=1)" and ended with status 1.
- Ctrl-C sends INT. With no INT trap, the signal ends the script with status 130 (128 + 2), and Bash 5.3 runs the EXIT trap first. A handler that lets the script finish its item comes in the next section.
- kill sends TERM. tmp_term.sh sent itself TERM with no TERM trap set: Bash ran the EXIT trap, printed "cleaned ./workspace" and ended with 143. dash 0.5.13.5 ran no EXIT trap for the same script and left the folder behind.
- SIGKILL leaves nothing to run. kill -9 and the Linux OOM killer send SIGKILL, which cannot be caught, blocked or ignored (signal(7)). The process ends at once and no trap runs, so anything the script was meant to clean up stays.
The one it misses is SIGKILL (kill -9 and the Linux Out-Of-Memory killer). signal(7) says it cannot be
caught, blocked or ignored: the process ends at once, and no shell code runs. Everything else runs the trap in
Bash: a plain exit, a false that set -e catches, a Ctrl-C that becomes SIGINT, and a kill -TERM from outside,
which the session’s tmp_term.sh showed by sending itself TERM with no TERM trap set. That is Bash’s behaviour, not
a rule of POSIX: dash 0.5.13.5 ran no EXIT trap for the same script and left the folder behind, so a script written
for sh also traps INT and TERM and exits from there (trap 'exit 143' TERM).
The rc=$? on the first line of a cleanup action is a small habit that pays back. Without it, the next command in
the trap resets $? to its own status; with it, the trap sees the status that caused the exit and can log it. The
tmp_errors.sh run printed cleaned ./workspace (rc=1) only because the trap started with rc=$?.
Key idea
Put the cleanup in an EXIT trap, not on the “happy path”: Bash runs it however the script ends (the last line, a
set -e stop, a Ctrl-C, an external TERM), and the one way out that skips it, SIGKILL, is also the only one that
no code in the script can handle.
Checkpoint
Check: When an EXIT trap fires, and the one time it does not
Single-quote the trap action, so variables expand at trap time
The third script in the previous session showed the slip. The action is one shell word, and Bash reads it like any
other string: with double quotes, every $name is expanded now, when the trap is being set; with single quotes
the string reaches trap as it was written, and the expansion happens when the trap fires. ShellCheck’s
SC2064 names the slip and the fix.
#!/bin/bash
# Trap action quoting: the string reaches `trap` once, and its expansions follow the quoting style.
# Read-only snippet.
# Wrong: $work expands now; the trap stores the current value.
trap "rm -rf -- $work" EXIT
# Right: $work expands when the trap runs, which is after the script has set $work.
trap 'rm -rf -- "$work"' EXIT The same rule covers $LINENO, $BASH_COMMAND and $? inside an ERR or DEBUG trap: single-quote the action
so each one reads the value that is current at trap time. A multi-line action is best written as a function and
registered by name:
#!/bin/bash
# A multi-line cleanup lives in a function; the trap registers it by name.
# Read-only snippet; $work and $pidfile come from the surrounding script.
cleanup() {
local rc=$?
rm -rf -- "$work"
[ -n "$pidfile" ] && rm -f -- "$pidfile"
echo "exited with $rc" >&2
}
trap cleanup EXIT A function also keeps the trap line short and puts the cleanup where it can be read, tested and changed like the rest of the script.
Checkpoint
Check: Single-quote the trap action for trap-time expansion
Catching INT and TERM, then exiting 130 or 143
SIGINT (number 2 on Linux and on macOS, written INT to trap) and SIGTERM (15, TERM) are the two polite
“please stop” signals. Without a trap, Bash ends the script and reports 130 for INT (128 + 2) or 143 for TERM
(128 + 15). With a trap, the handler runs — and it must call exit to leave; otherwise, after the handler returns,
the shell goes back to what it was doing. A worker that finishes its current item and exits 143 is the standard
form:
from session import run, write
# A worker that handles TERM, finishes the item it is on, cleans up and exits 143 (128 + 15, for SIGTERM).
# For a reproducible session, the script sends TERM to itself after the second item so no second shell is needed.
# In production, the TERM would come from a scheduler, a service manager stopping the job, or `kill` from an admin.
write("worker.sh", """#!/bin/bash
set -euo pipefail
STOP=0
cleanup() {
local rc=$?
rm -f -- state.json
echo "cleaned state.json (rc=$rc)"
}
trap cleanup EXIT
trap 'STOP=1; echo "received TERM at item $i"' TERM
for i in 1 2 3 4; do
echo "processing item $i"
printf '{"last": %d}\\n' "$i" > state.json
# Simulate an external 'kill -TERM' arriving while we are between items 2 and 3.
if [ "$i" = 2 ]; then
kill -TERM $$
fi
if (( STOP == 1 )); then
echo "stopping gracefully after item $i"
exit 143
fi
done
echo "all items done"
""")
run("""
chmod +x worker.sh
!bash worker.sh
echo "worker exit status: $?"
""") Output
$ chmod +x worker.sh $ bash worker.sh processing item 1 processing item 2 received TERM at item 2 stopping gracefully after item 2 cleaned state.json (rc=143) [exit status 143] $ echo "worker exit status: $?" worker exit status: 143
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 graceful_worker_session.py
The handler set STOP=1 and printed the item at which TERM arrived. The main loop finished item 2, saw STOP,
printed stopping gracefully after item 2, and exited 143. The EXIT trap then removed state.json and reported
rc=143. The pattern:
- Set
trapearly, before the loop that handles items, so a signal between items is handled too. - Keep the handler short. Set a flag and let the loop act on it. Bash runs a trap only between commands: while a
foreground command such as
sleep 60runs, the handler waits for it to finish (the Bash manual, Signals). Start a long wait assleep 60 & wait $!instead, becausewaitreturns at once when a trapped signal arrives. - Exit with the conventional status.
exit 130for INT andexit 143for TERM give a caller that reads$?the same number as a death by that signal, while scripts that forget report 0 and look as though they finished the job. The script still ended normally, though, and a calling Bash script can tell: it treats a SIGINT as fatal only when the command it was waiting for was killed by it (the Bash manual, Signals). To end as if killed, reset the trap and send the signal again:trap - INT; kill -INT $$.
Ignoring SIGHUP keeps a long job alive when its terminal closes
When a terminal closes, its shell receives SIGHUP, and an interactive Bash sends it on to every job before it
exits (the Bash manual, Signals). A job that must survive can be started with nohup, taken off the job list
with disown, or can ignore the signal itself with trap '' HUP, since an empty action means ignore. The
processes-jobs-and-signals module of this track covers running scripts in the background.
Checkpoint
Check: Handling INT and TERM, and exiting 130 or 143
ERR, RETURN and DEBUG, and the subshell reset
Three Bash traps are not signals but shell events. The A name that trap accepts in place of a signal for an event of the shell itself: EXIT when the shell exits, ERR when a command fails outside the spots where set -e looks away, RETURN when a function or sourced file returns, DEBUG before each command. names are:
ERR: runs when a command fails, except in the spots whereset -elooks away, whether or notset -eis on. Not inherited into a function unlessset -E(errtrace) is on.RETURN: runs when a function or a sourced file returns to its caller. Not inherited unlessset -T(functrace).DEBUG: runs before every simple command (and before a few compound ones, such asforandcase). Also inherited only withset -T.
#!/bin/bash
# errtrace (set -E) makes the ERR trap inherit into functions and $() substitutions.
# Read-only snippet; a real script opens with this at the top.
set -eE
trap 'echo "ERR at line $LINENO: $BASH_COMMAND"' ERR from session import run, write
# ERR runs when a command fails, outside the spots where set -e looks away. By default the ERR trap does not reach
# into a function; set -E (errtrace) makes it inherited. set -T (functrace) does the same for RETURN and DEBUG.
# A subshell resets every trap the shell has set, EXIT included (ignored signals stay ignored), so the parent's EXIT
# trap does not run when a subshell ends, and a trap set inside ( ... ) fires only when that subshell ends.
# trap -p lists what is set. POSIX.1-2024 added trap -p; dash 0.5.13.5 rejects it, and because trap is a special
# builtin the dash script ends with status 2.
write("err_demo.sh", """#!/bin/bash
set -eE
trap 'echo "ERR at line $LINENO: $BASH_COMMAND" >&2' ERR
echo "about to call a function"
greet() { echo "hello"; false; }
greet
echo "unreachable"
""")
write("subshell_reset.sh", """#!/bin/bash
trap 'echo "parent EXIT fired"' EXIT
( echo "inside a subshell that sets no trap of its own" )
(
echo "inside subshell before setting its trap"
trap 'echo "subshell EXIT fired"' EXIT
echo "inside subshell after setting its trap"
)
echo "subshell ended; parent still running"
""")
run("""
chmod +x err_demo.sh subshell_reset.sh
!bash err_demo.sh
bash subshell_reset.sh
# trap -p lists what the shell has set:
bash -c "trap 'echo hi' EXIT; trap -p EXIT"
# POSIX.1-2024 added trap -p; dash 0.5.13.5 rejects it, and because trap is a special builtin the dash script ends:
!dash -c "trap 'echo hi' EXIT; trap -p EXIT; echo after"
""") Output
$ chmod +x err_demo.sh subshell_reset.sh $ bash err_demo.sh about to call a function hello ERR at line 6: false [exit status 1] $ bash subshell_reset.sh inside a subshell that sets no trap of its own inside subshell before setting its trap inside subshell after setting its trap subshell EXIT fired subshell ended; parent still running parent EXIT fired # trap -p lists what the shell has set: $ bash -c "trap 'echo hi' EXIT; trap -p EXIT" trap -- 'echo hi' EXIT hi # POSIX.1-2024 added trap -p; dash 0.5.13.5 rejects it, and because trap is a special builtin the dash script ends: $ dash -c "trap 'echo hi' EXIT; trap -p EXIT; echo after" dash: 1: trap: Illegal option -p hi [exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 err_debug_session.py
In the session, err_demo.sh has set -eE, so the ERR trap reached into greet and reported “ERR at line 6: false” the moment false failed. subshell_reset.sh showed the second rule, which the Bash manual states for
every subshell: traps the shell has set are reset, EXIT included, while ignored signals (trap '' SIG) stay
ignored. The first ( … ) sets no trap and ended without running the parent’s EXIT trap; the second set its own,
which fired when it ended; the parent’s fired once, when the script ended.
trap -p lists what is currently set, printing a line per signal that reads like the trap command used to set it;
POSIX.1-2024 added -p to the standard (its change history lists it for Issue 8), and Bash supports it. dash
0.5.13.5 does not yet, and because trap is a POSIX special builtin, a syntax error in it ends the shell:
dash -c "trap -p EXIT; echo after" printed the usage error and then the EXIT handler’s output (“hi”), status 2,
with no “after”. For a listing that works in every POSIX shell, dash included, run trap with no arguments.
Expecting an ERR trap to fire inside every function
Wrong: set -e; trap … ERR; f() { false; }; f and waiting for the trap. Right: set -eE; trap … ERR; f() { false; }; f. The Bash manual says ERR is not inherited by functions unless errtrace is on; the same applies to
DEBUG and RETURN under functrace.
Checkpoint
Check: ERR, RETURN, DEBUG and the subshell reset
Practise: predicting what a trap does
Each question changes one detail of the sessions: a signal name, a missing set -E, a double-quoted action, a
long command running when TERM arrives. Predict the result first; the explanations name the run behind each answer:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what does an EXIT trap, trap 'commands' EXIT, guarantee, and what does it not?
It guarantees that Bash runs commands when the shell exits: at the end of the script, at an exit, when
set -e stops it, and, in Bash 5.3, when an INT, TERM or HUP with no trap of its own ends it. It does not cover
SIGKILL (kill -9, the Linux OOM killer), which signal(7) says cannot be caught, and it does not promise that
commands finish: a power cut can stop them half-way. It is also not portable: dash runs no EXIT trap when a
signal ends the script, so a script for sh traps INT and TERM as well. Write the cleanup so that it is safe to
run twice and touches only what the script created.
Two questions follow, both about cleanup that went wrong in production: a lock file that outlived its service, and a trap that removed a folder it was never meant to touch. Think through your first step for each before you open it:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A service writes /var/run/import.lock when it starts and removes it in an EXIT trap. After the OOM killer ended it, the lock stayed, and every later start refused with "lock held". No trap can run after SIGKILL, so design a lock that recovers without one, and say what each design can still get wrong.
-
Mid to senior
A build script sets workdir=./out at the top. A helper function declares local workdir, fills it with mktemp -d and sets trap 'rm -rf -- "$workdir"' EXIT, single-quoted as ShellCheck asks. When the script ended, ./out and its results were gone and the temporary folder was still there. Explain what ran, and fix it.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does trap 'action' SIGNAL do?
It registers action to run when SIGNAL, or one of the shell events EXIT, ERR, RETURN and DEBUG, reaches the shell. The action is one shell word.
-
Why should a trap action be in single quotes?
So its variables expand when the trap runs, not when it is set; double quotes freeze the values of the moment, the slip ShellCheck names SC2064.
-
When does an EXIT trap run in Bash, and when can it not?
However the shell ends: the last line, exit, a set -e stop, or an INT or TERM. It never runs after SIGKILL, which no process can catch, and dash runs it on none of these signals.
-
Which status should a script exit with after trapping INT or TERM?
The handler does not exit on its own, so write exit 130 for INT and exit 143 for TERM, which is 128 plus the signal number, and the caller sees the right code.
-
How much work belongs in a signal handler?
Very little: set a flag and let the main loop act on it. Bash runs a trap only between commands, so a foreground sleep 60 delays the handler; start a long wait as sleep 60 & wait $!, because wait returns at once when a trapped signal arrives.
-
Why does an ERR trap not fire inside functions by default?
Functions do not inherit it unless set -E is on. RETURN and DEBUG traps need set -T for the same reason.
-
What happens to traps in a subshell, and how do you list them?
A subshell resets every trap except ignored signals, so a trap set inside ( ... ) fires when that subshell ends. trap -p lists the current traps in Bash and POSIX.1-2024, but dash 0.5.13.5 rejects -p.
Key takeaways
trap 'action' SIGNALregistersactionto run whenSIGNAL(orEXIT,ERR,RETURN,DEBUG) reaches the shell. The action is one shell word; single-quote it so variables expand at trap time, as ShellCheck’s SC2064 names.- In Bash, an EXIT trap runs however the shell ends (the last line,
exit, aset -estop, an INT or TERM) but never after aSIGKILL, whichsignal(7)says cannot be caught; dash runs it on none of the signals. - A trapped INT or TERM runs the handler but does not exit on its own: write
exit 130for INT andexit 143for TERM so the caller sees the right code, and keep the handler’s work small (set a flag; let the main loop finish its item, and wait for long commands withwait). ERRruns when a command fails outside the spots whereset -elooks away; it is not inherited by functions unlessset -Eis on.RETURNandDEBUGneedset -Tfor the same reason.- A subshell resets every trap but ignored signals, so a trap set inside
( … )fires only when the subshell ends;trap -plists the current traps in Bash and in POSIX.1-2024, while dash 0.5.13.5 rejects-p, and becausetrapis a special builtin that error ends the dash script.
Terms in this lesson
- trap
- A Bash builtin that registers code to run on a signal or a shell event: EXIT when the shell exits, ERR when a command's failure would end a set -e script, RETURN when a function returns, DEBUG before every simple command. trap action signal …; the action is one shell word.
References
- Bash Reference Manual: Bourne Shell Builtins (trap) (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Signals (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: trap (special built-in) (The Open Group and IEEE)
- signal(7): Linux signal manual (man7.org (Michael Kerrisk))
- Bash Reference Manual: Command Execution Environment (traps in subshells) (Chet Ramey (GNU Bash maintainer))
- ShellCheck wiki: SC2064 (quote so variables expand at trap time) (ShellCheck)
Report a problem with this lesson
Kept only in this browser. Your Learn progress