Shell by Example: Traps POSIX + Bash

The trap command runs code when your script receives a signal. Syntax: trap ‘commands’ SIGNAL

The most common signal is EXIT, which fires when the script ends for any reason - normal completion, error, or interrupt.

Edit
#!/bin/sh
trap 'echo "Goodbye!"' EXIT

echo "Script is running..."
echo "About to exit..."
Output:
Script is running...
About to exit...
Goodbye!

Scripts often create temporary files that must be cleaned up. Without trap, if a script crashes or is interrupted, temp files leak. With trap, cleanup runs automatically on exit.

Edit
#!/bin/sh
tmpfile="/tmp/demo_$$"
trap 'rm -f "$tmpfile"' EXIT

touch "$tmpfile"
echo "Created: $tmpfile"
echo "File exists: $(test -f "$tmpfile" && echo yes)"
echo "Exiting - trap will clean up..."
Output:
Created: /tmp/demo_6
File exists: yes
Exiting - trap will clean up...

Traps are also available in subshells, and will fire when the specified signal is received for that subshell.

Edit
#!/bin/sh
(
    trap 'echo "Goodbye!"' EXIT

    echo "Script is running..."
    echo "About to exit..."
)
Output:
Script is running...
About to exit...
Goodbye!

Common signals

  • EXIT (script end)
  • INT (Ctrl+C)
  • TERM (kill)
  • HUP (terminal close).
Edit
#!/bin/sh
# EXIT runs when script ends
echo "EXIT signal:"
(
    trap 'echo "  EXIT signal caught"' EXIT
    echo "  Trap set for EXIT"
)

echo ""

# INT catches Ctrl+C
echo "INT signal (Ctrl+C):"
(
    trap 'echo "  INT signal caught"' INT
    echo "  Trap set for Ctrl+C, won't run as script ends normally"
)

echo ""

# TERM catches kill command
echo "TERM signal (kill):"
(
    trap 'echo "  TERM signal caught"' TERM
    echo "  Trap set for TERM, won't run as script ends normally"
)
Output:
EXIT signal:
  Trap set for EXIT
  EXIT signal caught

INT signal (Ctrl+C):
  Trap set for Ctrl+C, won't run as script ends normally

TERM signal (kill):
  Trap set for TERM, won't run as script ends normally

You can trap multiple signals with the same handler by listing them after the command.

Edit
#!/bin/sh
trap 'echo "  Signal caught - cleaning up..."' INT TERM

echo "Same handler for INT and TERM"
echo "Exiting normally (neither signal sent)"
Output:
Same handler for INT and TERM
Exiting normally (neither signal sent)

Traps can run multiple commands by separating them with semicolons.

Edit
#!/bin/sh
trap 'echo "Saving..."; echo "Closing..."' EXIT

echo "Working..."
Output:
Working...
Saving...
Closing...

For complex cleanup, use a function instead of inline commands. This keeps the trap readable and allows multi-step cleanup.

Edit
#!/bin/sh
cleanup() {
    rm -f "$TMPFILE"
    echo "Cleanup done"
}

TMPFILE=$(mktemp)
trap cleanup EXIT

echo "Created: $TMPFILE"
echo "Working with file..."
Output:
Created: /tmp/tmp.stab000000
Working with file...
Cleanup done

EXIT traps run even when scripts exit with an error. This ensures cleanup happens regardless of how the script ends.

Edit
#!/bin/sh
trap 'echo "Cleanup ran"' EXIT

echo "About to exit with error..."
exit 1
Output:
About to exit with error...
Cleanup ran

Variables in traps are evaluated when the trap runs, not when it’s defined. Use single quotes to get this delayed evaluation.

Edit
#!/bin/sh
value="initial"
trap 'echo "At exit: $value"' EXIT
value="changed"

echo "Value is now: $value"
Output:
Value is now: changed
At exit: changed

Inside a trap, $? contains the exit code that triggered the trap. This lets you log or react to how the script exited.

Traps don’t change the script’s exit code. The original exit status is preserved after the trap runs.

Edit
#!/bin/sh
trap 'echo "Trap saw exit code: $?"' EXIT

echo "Exiting with code 42..."
exit 42
Output:
Exiting with code 42...
Trap saw exit code: 42

Subshell traps are independent of the parent shell. A trap set in a subshell doesn’t affect the parent, and vice versa.

Edit
#!/bin/sh
trap 'echo "Parent trap"' EXIT

(
    trap 'echo "Child trap"' EXIT
    echo "In subshell..."
)

echo "Back in parent..."
trap - EXIT
echo "Parent trap cleared"
Output:
In subshell...
Child trap
Back in parent...
Parent trap cleared

Setting a new trap replaces the previous one for that signal. Only one trap handler can be active per signal.

Edit
#!/bin/sh
trap 'echo "First handler"' EXIT
echo "First trap set"

trap 'echo "Second handler"' EXIT
echo "Second trap set (replaces first)"
Output:
First trap set
Second trap set (replaces first)
Second handler

Use trap - SIGNAL to reset a trap to default behavior. Use trap '' SIGNAL to ignore a signal entirely.

Edit
#!/bin/sh
# Reset trap to default
echo "Reset trap:"
(
    trap 'echo "  Custom handler"' EXIT
    echo "  Custom trap set"
    trap - EXIT
    echo "  Trap reset (no output on exit)"
)

echo ""

# Ignore a signal with empty string
echo "Ignore signal:"
trap '' INT
echo "  Ctrl+C is now ignored"
trap - INT
echo "  Ctrl+C restored"
Output:
Reset trap:
  Custom trap set
  Trap reset (no output on exit)

Ignore signal:
  Ctrl+C is now ignored
  Ctrl+C restored

Bash

Use trap -p to view currently set traps.

Edit
#!/bin/bash
trap 'echo cleanup' EXIT
trap 'echo interrupted' INT

echo "Current traps:"
trap -p | while read -r line; do
    echo "  $line"
done

trap - EXIT INT
Output:
Current traps:
  trap -- 'echo cleanup' EXIT
  trap -- 'echo interrupted' SIGINT

Bash

Bash adds ERR, which runs when any command exits non-zero. Use $? to see the exit status, $BASH_COMMAND for the command.

Edit
#!/bin/bash
trap 'echo "ERR: $BASH_COMMAND failed with status $?"' ERR

echo "Running commands..."
true
false
test -d /nonexistent
echo "Script continues after errors"
Output:
Running commands...
ERR: false failed with status 1
ERR: test -d /nonexistent failed with status 1
Script continues after errors

Bash

Bash adds DEBUG, which runs before each command executes. $BASH_COMMAND contains the command about to run.

Edit
#!/bin/bash
trap 'echo "TRACE: $BASH_COMMAND"' DEBUG

x=5
y=10
sum=$((x + y))

trap - DEBUG
echo "Tracing disabled, sum=$sum"
Output:
TRACE: x=5
TRACE: y=10
TRACE: sum=$((x + y))
TRACE: trap - DEBUG
Tracing disabled, sum=15

Bash

Bash adds RETURN, which runs when a function or sourced script returns. Useful for cleanup after function execution.

Edit
#!/bin/bash
cleanup_after() {
    echo "  RETURN: function finished"
}

my_function() {
    trap cleanup_after RETURN
    echo "  Inside function"
}

echo "Calling function:"
my_function
echo "Back in main script"
Output:
Calling function:
  Inside function
  RETURN: function finished
Back in main script

Lock files prevent multiple instances of a script from running. The trap ensures the lock is released even if the script fails.

Edit
#!/bin/sh
lockfile="/tmp/myapp_lock_$$"
release_lock() {
    rm -f "$lockfile"
    echo "Lock released: $lockfile"
}

trap release_lock EXIT

echo $$ >"$lockfile"
echo "Lock acquired: $lockfile"

sleep 1
echo "Work complete"
Output:
Lock acquired: /tmp/myapp_lock_7
Work complete
Lock released: /tmp/myapp_lock_7

Use a flag variable to gracefully exit loops when interrupted, allowing the current iteration to complete.

Edit
#!/bin/sh
running=true
trap 'running=false; echo "Shutdown requested..."' INT TERM

count=0
while [ "$running" = true ] && [ "$count" -lt 3 ]; do
    count=$((count + 1))
    echo "Processing item $count"
    sleep 1
done

echo "Graceful shutdown complete"
Output:
Processing item 1
Processing item 2
Processing item 3
Graceful shutdown complete

Log cleanup actions to help debug issues. Check return values to report success or failure.

Edit
#!/bin/sh
tmpdir=$(mktemp -d)
touch "$tmpdir/file1" "$tmpdir/file2"

cleanup_with_log() {
    if rm -rf "$tmpdir" 2>/dev/null; then
        echo "Cleanup: success"
    else
        echo "Cleanup: failed"
    fi
}
trap cleanup_with_log EXIT

echo "Created: $tmpdir"
echo "Files: $(ls "$tmpdir" | tr '\n' ' ')"
echo "Exiting..."
Output:
Created: /tmp/tmp.stab000000
Files: file1 file2 
Exiting...
Cleanup: success

Set the trap before creating resources. This ensures cleanup runs even if resource creation fails partway through.

Edit
#!/bin/sh
tmpfile=""
cleanup() {
    [ -n "$tmpfile" ] && [ -f "$tmpfile" ] && rm -f "$tmpfile"
    echo "Cleanup complete"
}
trap cleanup EXIT

tmpfile=$(mktemp)
echo "Created: $tmpfile"
echo "Working with file..."
Output:
Created: /tmp/tmp.stab000000
Working with file...
Cleanup complete

Handle EXIT for normal cleanup, plus INT (Ctrl+C) and TERM (kill) to ensure cleanup runs on interruption.

Edit
#!/bin/sh
cleanup() {
    echo "Cleanup ran"
}

trap cleanup EXIT
trap 'echo "Interrupted!"; exit 1' INT
trap 'echo "Terminated!"; exit 1' TERM

echo "Script handles EXIT, INT, and TERM"
echo "Cleanup always runs via EXIT trap"
Output:
Script handles EXIT, INT, and TERM
Cleanup always runs via EXIT trap
Cleanup ran

Track resources in variables so the cleanup function knows what to clean up, even if resources are created dynamically.

Edit
#!/bin/sh
TEMP_FILES=""

add_temp() {
    TEMP_FILES="$TEMP_FILES $1"
}

cleanup_all() {
    for f in $TEMP_FILES; do
        echo "Cleaning up $f"
        rm -f "$f" 2>/dev/null
    done
    echo "All resources cleaned"
}
trap cleanup_all EXIT

f1=$(mktemp)
add_temp "$f1"
f2=$(mktemp)
add_temp "$f2"
echo "Tracking:$TEMP_FILES"
Output:
Tracking: /tmp/tmp.stab000000 /tmp/tmp.stab000001
Cleaning up /tmp/tmp.stab000000
Cleaning up /tmp/tmp.stab000001
All resources cleaned

« Exit Codes | Command Line Arguments »