Clean Up with trap
Problem statement
Make a script that creates a temporary folder and always removes it when it ends: after success, after a failed step, and when it is stopped with TERM. Scripts that leave temp files, lock files or half-written output behind slowly fill disks and break the next run.
The script saves the tool as work.sh and runs it three ways. Each run writes the path of its temp folder to where.txt, so the outer script can check afterwards that the folder is gone.
- A normal successful run.
- A run where a step fails (with
set -e, the script stops). - A run that is stopped with
TERMwhile it waits.
After each run, print the exit code and whether the temp folder still exists.
Expected output:
== run 1: success == working in a temp folder all steps done cleanup: temp folder removedexit code: 0temp folder still there: no== run 2: a step fails == working in a temp folder step 2 fails cleanup: temp folder removedexit code: 1temp folder still there: no== run 3: stopped with TERM == working in a temp folder waiting... cleanup: temp folder removedexit code: 143temp folder still there: noHints
trap cleanup EXIT runs the cleanup function whenever the script exits, for any reason, including set -e stopping it.Approach
Optimal: mktemp -d with trap EXIT
Covers: mktemp -d, trap 'command' EXIT, trapping INT and TERM, exit codes 130 and 143, rm -rf -- "${tmp:?}", what cannot be trapped, strict mode piece by piece.
Strict mode, used on every page in this section. The second line, set -euo pipefail, makes bash stop on mistakes instead of carrying on:
| Option | Means |
|---|---|
-e |
exit as soon as a command fails (with some exceptions, see Handle Command Failures) |
-u |
treat an unset variable as an error, instead of silently using empty text |
-o pipefail |
a pipeline fails if any command in it fails, not just the last one |
Put it right after the shebang line #!/usr/bin/env bash in every script you write.
Create temp space safely. mktemp -d makes a new, empty folder with a random name under /tmp, readable only by you, and prints its path. Never use a fixed name like /tmp/work: two runs would clash, and another user could create it first.
trap runs code when the script ends. trap cleanup EXIT tells bash: "whenever this script exits, run cleanup first". That covers a normal end, an exit 3, and set -e stopping it on a failed command:
Signals need one more line. When the script is stopped by TERM (what kill and systemctl stop send) or INT (Ctrl+C), bash dies at once unless that signal is trapped. Turning the signal into an exit makes the EXIT trap run too:
trap cleanup EXITtrap 'exit 143' TERM 128 + 15, the usual code for "stopped by TERM"trap 'exit 130' INT 128 + 2, the usual code for Ctrl+CKILL (signal 9) can never be trapped, so nothing runs. That is why you send TERM first, as on the Find and Stop a Process page.
Delete carefully. rm -rf -- "${tmp:?}" has two guards. ${tmp:?} stops the script if tmp is empty or unset, so a bug can never turn it into rm -rf "" or worse. -- ends the options, so a path starting with - is not read as an option.
EXIT trap cleans up"]:::green T --> N["bash dies
temp folder left behind"]:::red classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px
Walking through the code. The # Setup: lines write the tool to work.sh, so skip past the setup part; the tool itself is the solution.
work.shmakes the temp folder, records its path, sets the traps, and does its work. Its argument choosesok,fail(runsfalse) orhang(waits on a backgroundsleep).- Run 1 succeeds, the trap cleans up, exit 0.
- Run 2:
falsestops the script with code 1, and the trap still cleans up. - Run 3 starts in the background, gets
TERMafter half a second, exits 143, and cleans up.sleep 3 & waitis used so the signal is handled at once; bash waits for a foreground command to finish before running a trap.
Edge cases. Setting trap ... EXIT twice replaces the first one; put all cleanup in one function. Inside the trap, $? is the code the script is exiting with, so cleanup can log it. A background job the script started keeps running after the script exits unless the cleanup stops it, which is what kill "$child" does here.
#!/usr/bin/env bash
set -euo pipefail
# Setup: write the tool in a fresh temporary folder
cd "$(mktemp -d)"
cat > work.sh << 'TOOL'
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp -d)
echo "$tmp" > where.txt # so the outer script can check it later
child=""
cleanup() {
if [[ -n $child ]]; then kill "$child" 2>/dev/null || true; fi
rm -rf -- "${tmp:?}"
echo " cleanup: temp folder removed"
}
trap cleanup EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
echo " working in a temp folder"
echo "data" > "$tmp/part1"
case "${1:-ok}" in
fail) echo " step 2 fails"; false ;;
hang) echo " waiting..."; sleep 3 & child=$!; wait "$child" ;;
esac
echo " all steps done"
TOOL
gone() { if [[ -d $(cat where.txt) ]]; then echo "temp folder still there: yes"; else echo "temp folder still there: no"; fi; }
echo "== run 1: success =="
bash work.sh ok && echo "exit code: 0"; gone
echo "== run 2: a step fails =="
bash work.sh fail || echo "exit code: $?"; gone
echo "== run 3: stopped with TERM =="
bash work.sh hang & pid=$!
sleep 0.5
kill -TERM "$pid"
wait "$pid" || echo "exit code: $?"; goneInterview follow-ups
Also create a lock file at the start and remove it on exit.
Create it right after the traps:
lock=/tmp/work.lock; [[ -e $lock ]] && { echo "already running" >&2; exit 1; }; echo $$ > "$lock". Addrm -f -- "$lock"tocleanup, so it is removed on success, failure andTERM. Be careful to set the lock only after checking it, otherwisecleanupwould delete another run's lock. For real use, preferflockfrom the FAQ, because aKILLwould still leave this file behind.
Frequently asked questions
In bash, an untrapped INT or TERM kills the script directly, and whether the EXIT trap still runs depends on the shell and version. Do not rely on it: add trap 'exit 130' INT and trap 'exit 143' TERM, which turn the signal into a normal exit, and the EXIT trap then always runs. Also remember that bash runs traps only after the current foreground command finishes, so a long sleep delays them; sleep ... & wait $! lets the trap run at once.
Because a script with an empty variable and rm -rf "$tmp/"* turns into rm -rf /*, which has deleted whole servers. ${tmp:?} makes bash stop with an error if tmp is empty or unset, before rm ever runs. set -u catches unset variables but not empty ones, so the :? guard adds real protection. Use it on every rm -rf that takes a variable.
Use flock, which holds a lock as long as a file descriptor is open: exec 9> /tmp/backup.lock; flock -n 9 || { echo "already running" >&2; exit 1; }. The lock is released automatically when the script exits, even if it crashes or is killed with KILL, because the kernel closes the descriptor. That is safer than creating and deleting a lock file yourself, which leaves a stale lock behind after a crash.