Skip to content
Go back

trap in Bash: Clean Up When Your Script Dies

By SumGuy 5 min read
trap in Bash: Clean Up When Your Script Dies

Your script creates a temporary directory and starts a process. Halfway through, something fails and the script exits. The temp directory is left behind. The process is still running. Your /tmp is full of garbage from failed runs.

trap is bash’s cleanup mechanism. It says “when this signal happens, run this code.” Use it to delete temp files, kill background processes, and shut down gracefully. It’s not optional — it’s how you write scripts that don’t leave messes.

Basic Trap Syntax

basic_trap.sh
#!/bin/bash
trap 'echo "Cleaning up..."; rm -rf /tmp/mytemp' EXIT
mkdir -p /tmp/mytemp
echo "Working in /tmp/mytemp"
exit 0

When the script exits (any exit), the trap runs: cleanup code executes, temp directory is deleted.

Terminal window
$ bash basic_trap.sh
Working in /tmp/mytemp
Cleaning up...
$ ls /tmp/mytemp
ls: cannot access '/tmp/mytemp': No such file or directory

The trap fired and cleaned up. Perfect.

Common Signals

EXIT — Runs when the script exits, for any reason (normal or error):

trap_exit.sh
#!/bin/bash
trap 'echo "Script ended"' EXIT
echo "Starting"
false # Error
echo "Never runs"
Terminal window
$ bash trap_exit.sh
Starting
Script ended

EXIT fires even though the script errored out.

INT — Runs when user presses Ctrl+C:

trap_int.sh
#!/bin/bash
trap 'echo "Interrupted"; exit 1' INT
echo "Press Ctrl+C to interrupt..."
while true; do
sleep 1
done
Terminal window
$ bash trap_int.sh
Press Ctrl+C to interrupt...
^CInterrupted

User pressed Ctrl+C, INT trap fired, script exited cleanly.

TERM — Runs when the script receives SIGTERM (from kill):

trap_term.sh
#!/bin/bash
trap 'echo "Terminating gracefully"; cleanup_function' TERM
cleanup_function() {
echo "Cleaning up..."
kill $bg_pid 2>/dev/null || true
}
echo "Starting background work..."
sleep 100 &
bg_pid=$!
wait

In another terminal:

Terminal window
$ kill -TERM <pid>

Script receives SIGTERM, trap fires, cleanup runs, script exits.

ERR — Runs when any command exits with non-zero status:

trap_err.sh
#!/bin/bash
set -E # Enable ERR trap inheritance
trap 'echo "Error on line $LINENO"; exit 1' ERR
echo "Starting"
false # Error
echo "Never runs"
Terminal window
$ bash trap_err.sh
Starting
Error on line 6

Real-World Pattern: Complete Cleanup

complete_trap.sh
#!/bin/bash
set -euo pipefail
TEMP_DIR=""
BG_PIDS=()
cleanup() {
local exit_code=$?
echo "Cleaning up..."
# Kill background processes
for pid in "${BG_PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
fi
done
# Remove temp directory
if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then
rm -rf "$TEMP_DIR"
fi
exit "$exit_code"
}
trap cleanup EXIT INT TERM
# Create temp directory
TEMP_DIR=$(mktemp -d)
echo "Using temp dir: $TEMP_DIR"
# Start background work
echo "Starting background task..."
sleep 100 &
BG_PIDS+=($!)
echo "Waiting for background tasks..."
wait
echo "Done"

Breakdown:

Trap Multiple Signals

Use one trap function for multiple signals:

trap_multi.sh
#!/bin/bash
cleanup() {
echo "Caught signal: exiting gracefully"
rm -f /tmp/lock
exit 0
}
trap cleanup EXIT INT TERM
echo "Running... press Ctrl+C"
while true; do
sleep 1
done

Or set separate traps:

trap_separate.sh
#!/bin/bash
trap 'echo "Normal exit"; rm -f /tmp/lock' EXIT
trap 'echo "Interrupted"; exit 1' INT
trap 'echo "Terminated"; exit 1' TERM
while true; do
sleep 1
done

Error Handling with trap ERR

Instead of relying on set -e (which has gotchas):

trap_err_safe.sh
#!/bin/bash
set -u
trap 'echo "Error on line $LINENO"; exit 1' ERR
echo "Step 1"
false # Error
echo "Never runs"
Terminal window
$ bash trap_err_safe.sh
Step 1
Error on line 7

This is more reliable than set -e because it fires on actual errors.

Combining with trap Debugging

trap_debug.sh
#!/bin/bash
trap 'echo "Line $LINENO: $BASH_COMMAND"' DEBUG
trap 'echo "Error on line $LINENO"; exit 1' ERR
echo "Step 1"
ls /nonexistent
echo "Never runs"
Terminal window
$ bash trap_debug.sh
Line 7: echo "Step 1"
Step 1
Line 8: ls /nonexistent
Error on line 8

DEBUG trap runs before every command. Useful for tracing execution.

Trap in Functions

Traps set in a function apply globally:

trap_in_func.sh
#!/bin/bash
setup_traps() {
trap 'echo "Function ended"' EXIT
}
setup_traps
echo "Main script"
Terminal window
$ bash trap_in_func.sh
Main script
Function ended

The trap set inside the function fires for the whole script.

The Pipeline Trap Pattern

For scripts that involve pipelines, lock files, or temporary state:

pipeline.sh
#!/bin/bash
set -euo pipefail
LOCK_FILE="/tmp/myapp.lock"
LOG_FILE="/tmp/myapp.log"
cleanup() {
local exit_code=$?
echo "Cleanup: removing lock and flushing logs" >&2
rm -f "$LOCK_FILE"
[ -f "$LOG_FILE" ] && tail -20 "$LOG_FILE" >&2
exit "$exit_code"
}
trap cleanup EXIT INT TERM
# Acquire lock
if [ -f "$LOCK_FILE" ]; then
echo "Another instance is running" >&2
exit 1
fi
touch "$LOCK_FILE"
# Do work, log everything
{
echo "Starting work: $(date)"
sleep 2
echo "Work complete: $(date)"
} | tee "$LOG_FILE"
echo "Pipeline successful"

On exit, cleanup removes the lock and shows the last 20 lines of the log.

The One Thing

Any script that:

Should have at least:

baseline.sh
#!/bin/bash
set -euo pipefail
cleanup() {
echo "Cleaning up..."
# Delete temp files
# Kill background pids
# Release locks
}
trap cleanup EXIT INT TERM
# ... rest of script

That pattern is your baseline for production-ready bash. No exceptions.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
WireGuard Is Fast, But You're Leaving Performance on the Table
Next Post
Docker Volumes vs Bind Mounts: Where Your Data Actually Lives

Related Posts