Consider the following:


trap 'echo $?' INT
kill -INT $$

Output: 0

Here I would expect 130 for my system. Of course, if I do a Ctrl + C then I get 130.

The same thing happens for any other signal like HUP or TERM. I found this behavior surprising because if you have a trap set up to capture many signals then there's no way to exit with the correct error code for what signal called your handler:


exit_abrupt() {
    echo "Encountered an error, cleaning up..." >&2
    # *clean up*

    exit "$exit_code" # This will return 0!

trap exit_abrupt HUP INT TERM
kill -HUP $$

I've tested Bash, Dash, and ZSH, all of which exhibit this behavior. Is this how it's supposed to work across shells? Is it documented by POSIX (can someone point to the documentation)? How else can I know the correct exit code that works across shells?

The best POSIX documentation I've found reads:

The value of "$?" after the trap action completes shall be the value it had before trap was invoked.

which to me sounds like it should be passing though the signal in all cases and yet it's not so I must be missing something...

2 Answers 2


$? contains the exit status of the last command that was run and waited for. You'll find that in:

$ bash -c 'trap "echo \$?" INT; sleep 10; exit'

130 was reported because both sleep and bash received SIGINT upon ^C, bash ran the handler after sleep returned and printed the exit status of that sleep command.


$ bash -c 'trap "echo \$?" INT; kill -s INT "$$"; exit'

You get the exit status of the kill command which was the last command bash ran before the SIGINT handler was invoked.

$ bash -c 'trap "echo \$?" INT; (trap "" INT; sleep 3; exit 123); exit'

sleep ignores SIGINT, you'll see that upon pressing Ctrl+C, you still have to wait for the subshell running sleep to return at which point the handler prints the exit status of that subshell (123).

If you want to install the same handler for several signals, you can pass the signal to the handler:

handler() {
  local signal="$1"
  echo "I got $signal signal"
for signal in INT HUP TERM QUIT; do
  trap "handler $signal" "$signal"

BTW, you'll find that:

bash -c '(trap "" INT; sleep 3; exit 123); exit'

Exits with a 123 exit status even when you try to interrupt it with Ctrl+c, and that's actually as designed in bash. More on that at When typing ctrl-c in a terminal, why isn't the foreground job terminated until it completes?

  • 1
    Thank you for this very informative answer. If you want to exit with the same signal received by the handler then I found this: kill -s "$signal" -- $$. Turns out kill is a builtin so this seems like the perfect way to do it (tested across Bash, Dash, and ZSH). Don't try the kill -l -- INT method because I found that doesn't work on Dash. With -- is the most portable variant as stated by POSIX. Jul 27, 2023 at 19:36

Here's my final solution for combining an EXIT trap with a fatal signal trap (whether received by keystroke or a signal sent to the shell process) while always passing through the correct exit status in case anyone wants:


set -e

# https://unix.stackexchange.com/questions/752570/why-does-trap-passthough-zero-instead-of-the-signal-the-process-was-killed-wit
handle_exit() {

    if [ "$exit_code" != 0 ] || [ "$signal" ]; then
        echo "\nProgram was exited abruptly!" >&2

    # Or do clean up here...

    if [ "$exit_code" != 0 ]; then
        trap -- - EXIT
        exit "$exit_code"
    elif [ "$signal" ]; then
        trap -- - "$signal"
        kill -s "$signal" -- $$

# POSIX sh doesn't include signals in its EXIT trap so list them ourselves
# SIG prefixes removed for POSIX sh compatibility
for signal in HUP INT TERM; do
    # shellcheck disable=SC2064
    trap "handle_exit $signal" "$signal"
trap handle_exit EXIT

# Your code starts here...

The trap -- - EXIT is necessary because if you trigger a literal INT (Ctrl + C) then the INT trap will rightfully go off before the EXIT trap. In this particular case (because of the literal Ctrl + C), $exit_code will also be set to 130. So, we remove remove the EXIT trap from the if branch to avoid triggering the handler twice. Similarly, trap -- - "$signal" avoids recursively calling the handler forever until stack overflow occurs.

Across all the shells (even Posh), it works like a charm.

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .