Linux / Bash

Bash set -e Not Exiting Script Inside Function or Subshell

set -e is meant to make a script exit immediately when any command fails, but its actual behavior has several well-documented, specific exceptions where a failing command doesn't trigger the expected exit — these exceptions are Bash's own documented (if often surprising) behavior, not a bug in your script.

The Problem

A script with set -e at the top continues running after a command that clearly failed, when you expected it to stop immediately:

#!/bin/bash
set -e

my_function() {
    false  # this command fails
    echo "This still prints, even though the command above failed"
}

my_function
echo "Script continues, even though set -e should have stopped it"
Where set -e silently doesn't apply if cmd; then cmd1 | cmd2 x=$(cmd) cmd && ... In each case, the failing command's exit status is "consumed" by the surrounding context

Why It Happens

set -e has documented exceptions where a command's failure doesn't trigger the script to exit, because Bash considers the failure to be "handled" by the surrounding context rather than left unaddressed:

  • A command that's part of a conditional expression (if command; then, while command; do, or before &&/||) doesn't trigger set -e, since testing whether a command succeeds or fails is precisely the conditional's entire purpose — set -e would make conditional logic completely impossible to write if failures there always exited the script
  • Only the last command in a pipeline determines whether set -e triggers by default — an earlier command in a multi-stage pipeline failing doesn't stop the script unless set -o pipefail is also explicitly enabled alongside set -e
  • The exit status of a function call is affected by this same pipeline/conditional logic internally — if the last command inside the function happens to be part of a conditional or isn't the actual failing command, the function's overall reported exit status may not reflect the earlier failure at all
  • Command substitution ($(command)) failing doesn't trigger set -e on its own — the failure is only relevant if the substitution's result is subsequently used somewhere that itself fails or is checked

The Fix

Enable pipefail alongside set -e, so a failure anywhere in a pipeline (not just the last command) correctly triggers the exit:

set -euo pipefail

This combination (-e for exit on error, -u for treating unset variables as an error, -o pipefail for full-pipeline failure detection) is widely considered the standard, safer baseline for production shell scripts, specifically because it closes several of the most common gaps in set -e's default behavior.

For failures inside functions specifically, don't rely on set -e alone propagating correctly through complex logic — explicitly check and propagate the function's return status where the stakes are high enough to warrant certainty:

my_function() {
    some_command || return 1
}

my_function || { echo "my_function failed"; exit 1; }

For command substitution specifically, explicitly check the exit status if the command's success genuinely matters, rather than assuming set -e alone catches a failure buried inside a substitution:

if ! result=$(some_command); then
    echo "command failed"
    exit 1
fi

As a general practice, treat set -e as a helpful safety net for genuinely simple, linear scripts, but don't rely on it as a complete substitute for explicit error checking in scripts with meaningful conditional logic, functions, or pipelines — the documented exceptions above mean it simply can't guarantee "any failure anywhere stops the script" the way its name might suggest.

Still Not Working?

If a script still doesn't exit as expected even with set -euo pipefail enabled, add explicit tracing to see exactly which commands are executing and with what exit status, rather than reasoning about the script's flow purely by reading it:

set -euo pipefail
set -x  # print each command before executing it, along with its expansion

Running the script with this tracing enabled makes the actual execution flow, including any unexpected continuation past a failing command, immediately visible in the output, which is far faster than manually reasoning through every conditional and pipeline in a longer script to find where an assumption about set -e's coverage doesn't actually hold.