Skip to content
Go back

bash `set -e` Doesn't Work Like You Think

By SumGuy 5 min read
bash `set -e` Doesn't Work Like You Think

You added set -e to your bash script and thought you were safe. Any command fails, the script exits. Perfect error handling, right?

Here’s the thing: set -e has so many exceptions that it’s almost useless without set -o pipefail and careful structuring. I’ve debugged scripts where set -e was present but the script didn’t actually stop on errors. Took me hours.

Let’s talk about the gotchas so you don’t repeat that mistake.

The Basics: set -e Works… Sometimes

set -e tells bash to exit immediately if any command exits with a non-zero status:

basic.sh
#!/bin/bash
set -e
echo "Starting"
false # This exits with status 1
echo "This never runs"

Run it:

Terminal window
$ bash basic.sh
Starting

The script exits. Good. But now read the rest.

Gotcha 1: set -e Ignores Subshells

subshell.sh
#!/bin/bash
set -e
echo "Starting"
(false) # Subshell! exit status is ignored
echo "This DOES run (the problem)"
Terminal window
$ bash subshell.sh
Starting
This DOES run (the problem)

The subshell fails but set -e doesn’t care. Why? Because set -e only stops if the immediate command exits non-zero. A subshell always exits with the status of its last command, but set -e doesn’t retroactively check that.

The same problem with command substitution:

subshell_sub.sh
#!/bin/bash
set -e
echo "Starting"
result=$(false) # Command substitution, error ignored
echo "Got result: $result"
Terminal window
$ bash subshell_sub.sh
Starting
Got result:

The false command failed, but set -e never saw it. It only sees the assignment success.

Gotcha 2: set -e Ignores Pipes (Unless You Use pipefail)

pipe.sh
#!/bin/bash
set -e
echo "Starting"
false | grep something # First command fails, but...
echo "This DOES run"
Terminal window
$ bash pipe.sh
Starting
This DOES run

Why? Because set -e checks the exit status of the pipeline, which is the last command. The last command is grep, which exits with 1 (no match), but that’s a normal grep failure. So set -e doesn’t care.

The fix is set -o pipefail:

pipe_fixed.sh
#!/bin/bash
set -e
set -o pipefail
echo "Starting"
false | grep something
echo "This DOES run... wait, actually it doesn't"

Now the pipeline fails (because false failed), and set -e catches it.

Gotcha 3: set -e Ignores Conditionals

conditional.sh
#!/bin/bash
set -e
echo "Starting"
if false; then
echo "In the if"
fi
echo "This DOES run"
Terminal window
$ bash conditional.sh
Starting
This DOES run

Inside an if condition, the command can fail and set -e doesn’t care. The if itself succeeds (the condition is just false, the command completed). set -e only stops if the if statement itself fails.

Similarly with while and until:

while.sh
#!/bin/bash
set -e
while false; do
echo "Never runs"
done
echo "This DOES run"

The while loop successfully completes (it just doesn’t iterate). No error.

Gotcha 4: set -e Ignores Negated Commands

negated.sh
#!/bin/bash
set -e
echo "Starting"
! false # Negation inverts the exit code
echo "This DOES run"
Terminal window
$ bash negated.sh
Starting
This DOES run

! false returns true (inverting the exit code), so set -e doesn’t trigger.

The Safe Pattern

Stop using just set -e. Use this instead:

safe_pattern.sh
#!/bin/bash
set -euo pipefail
# ... rest of script

Breakdown:

This catches most problems.

But you still need to be careful with subshells and conditionals.

Explicit Error Handling for Tricky Cases

For subshells:

subshell_fixed.sh
#!/bin/bash
set -euo pipefail
result=$(false) # This still won't trigger set -e
# Fix it:
result=$(false || true)
if [ -z "$result" ]; then
echo "Command failed"
exit 1
fi

Or better, avoid the subshell:

subshell_better.sh
#!/bin/bash
set -euo pipefail
if ! false; then
echo "Command failed"
exit 1
fi

For conditionals, be explicit:

conditional_fixed.sh
#!/bin/bash
set -euo pipefail
# Instead of:
# if some_command; then
# ...
# fi
# Do:
some_command || exit 1
if [ $? -eq 0 ]; then
echo "Command succeeded"
fi

The Trap Alternative

For really critical scripts, use trap:

trap_pattern.sh
#!/bin/bash
set -u
trap 'echo "Error on line $LINENO"; exit 1' ERR
echo "Starting"
false # This now triggers the trap
echo "Never runs"

trap ERR fires on any error, regardless of set -e quirks. It’s more reliable for complex scripts.

Real-World Example: Safe Deployment Script

deploy.sh
#!/bin/bash
set -euo pipefail
trap 'echo "Deployment failed"; exit 1' ERR
echo "Pulling code..."
git pull || { echo "Git pull failed"; exit 1; }
echo "Running tests..."
npm test || { echo "Tests failed"; exit 1; }
echo "Building..."
npm run build || { echo "Build failed"; exit 1; }
echo "Restarting service..."
sudo systemctl restart myapp || { echo "Restart failed"; exit 1; }
echo "Deployment successful"

Each step has explicit error handling. No silent failures.

The One Thing

Before you ship that script:

Terminal window
$ head -5 your_script.sh
#!/bin/bash
set -euo pipefail
trap 'echo "Error: line $LINENO"; exit 1' ERR

That’s your baseline. Then review any pipes, subshells, and conditionals. Make them explicit. Your 2 AM self will thank you when the script actually stops on error instead of silently plowing ahead.


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
Flowise vs Langflow: Build AI Pipelines Without Writing a Novel
Next Post
Proxy Chains and Anonymization: What Actually Works and What's Just Theater

Related Posts