Code of the Day
AdvancedShell mastery

Traps and Signals

Register cleanup code with trap EXIT/INT/TERM, understand Unix signals, use kill and wait for process management, and handle subshell signal propagation.

BashAdvanced11 min read
Recommended first
By the end of this lesson you will be able to:
  • Register handlers for EXIT, INT, and TERM signals with trap
  • List common Unix signals and their meanings
  • Clean up temp files reliably with trap EXIT
  • Send signals to processes with kill and wait for them with wait
  • Explain how signals propagate through subshells and pipelines

Production scripts run in unpredictable environments: the user presses Ctrl-C, the system sends SIGTERM before a reboot, a dependent service fails and the script is killed. Without handling, these interruptions leave stale lock files, half-written outputs, and dangling background processes. is Bash's mechanism for registering cleanup code that runs no matter how the script ends.

Unix signals

A signal is an asynchronous notification sent to a process. Common ones:

SignalNumberDefault actionSent by
SIGHUP1TerminateTerminal closed
SIGINT2TerminateCtrl-C
SIGTERM15Terminatekill (default), system shutdown
SIGKILL9Force kill (uncatchable)kill -9
SIGPIPE13TerminateWrite to closed pipe
SIGUSR110TerminateApplication-defined

Scripts cannot catch SIGKILL — it goes directly to the kernel. For everything else, trap lets you intercept and handle the signal.

trap syntax

trap 'command or function' SIGNAL [SIGNAL2 ...]
trap 'cleanup' EXIT          # runs when the shell exits (any reason except SIGKILL)
trap 'cleanup' INT TERM      # runs on Ctrl-C or kill <pid>
trap '' SIGPIPE               # ignore SIGPIPE (useful for writer scripts)
trap - SIGINT                 # restore default behaviour for SIGINT

The command is evaluated in the current shell at the time the signal is received, so variable values at that point are used.

trap EXIT for reliable cleanup

trap EXIT is the most important form — it fires when the script exits for any reason (including set -e failures):

#!/usr/bin/env bash
set -euo pipefail

TMPDIR=$(mktemp -d)
LOCKFILE="/var/run/myscript.lock"

cleanup() {
  rm -rf "$TMPDIR"
  rm -f "$LOCKFILE"
  echo "Cleaned up." >&2
}
trap cleanup EXIT

# Acquire lock
touch "$LOCKFILE"

# Work here — any failure exits cleanly via set -e, and cleanup runs
cp data.csv "$TMPDIR/"
process "$TMPDIR/data.csv"

Handling INT and TERM explicitly

Sometimes you need to distinguish between a normal exit and an interruption — for example, to log that the script was cancelled:

interrupted=false

on_int() {
  interrupted=true
  echo "Interrupted by user" >&2
}
trap on_int INT

do_work() {
  for i in $(seq 1 100); do
    "$interrupted" && break
    process_item "$i"
  done
}

do_work
"$interrupted" && echo "Run was incomplete" >&2 || echo "Run completed"

Don't call exit from inside a SIGINT handler if you want the shell to re-raise INT. The convention is to reset the handler to default and re-kill the process: trap - INT; kill -INT $$. This lets the parent process see the correct exit status (terminated by SIGINT) rather than a clean exit.

kill and wait

kill sends a signal to a process; wait waits for a background process to finish:

# Start a background job
sleep 100 &
bgpid=$!

# Do other work
echo "Background pid: $bgpid"

# Wait for it
wait "$bgpid"
echo "Background job finished with exit $?"

# Or terminate it
kill "$bgpid"         # send SIGTERM (polite)
sleep 1
kill -0 "$bgpid" 2>/dev/null && kill -9 "$bgpid"  # force-kill if still running

kill -0 $pid is a test: it sends no signal but returns 0 if the process exists and you have permission to signal it, non-zero otherwise.

Signals in subshells and pipelines

Each element of a pipeline runs in a subshell. Signals sent to the parent shell are not automatically forwarded to the pipeline's subshells:

# This background pipeline child won't receive the parent's trap
sleep 100 | cat &
# Killing the parent doesn't necessarily kill "sleep 100"

For reliable cleanup of background processes, store their PIDs and kill them explicitly in the EXIT trap:

pids=()

cleanup() {
  for pid in "${pids[@]}"; do
    kill "$pid" 2>/dev/null
  done
  wait "${pids[@]}" 2>/dev/null
}
trap cleanup EXIT

sleep 100 & pids+=($!)
sleep 200 & pids+=($!)
# cleanup kills both when the script exits

wait without arguments waits for all background jobs started by the current shell. With a PID, it waits for just that process. In an EXIT trap that kills background jobs, call wait "${pids[@]}" after the kill calls to avoid zombie processes.

Check your understanding

  1. 1.
    Which signal should you trap to run cleanup code whenever the script exits — including on Ctrl-C and set -e failures?
  2. 2.
    kill -0 $pid returns 0. What does this tell you?
  3. 3.
    A script can catch SIGKILL with trap to run cleanup before being killed.

Do it yourself

#!/usr/bin/env bash
set -euo pipefail

tmpdir=$(mktemp -d)
echo "Created $tmpdir"

cleanup() {
  echo "Cleaning up $tmpdir"
  rm -rf "$tmpdir"
}
trap cleanup EXIT

# Simulate work
echo "working" > "$tmpdir/data.txt"
sleep 2   # Press Ctrl-C here to test INT handling
cat "$tmpdir/data.txt"
echo "Done normally"

Run it, let it complete, then run it again and press Ctrl-C during the sleep. The cleanup message should appear in both cases.

Where to go next

You've completed the Shell mastery module. The lab is next for quiz-based review, then the Automation module: cron scheduling, Makefiles as task runners, writing CI-grade shell scripts, and debugging techniques.

Finished reading? Mark it complete to track your progress.

On this page