Skip to content
Go back

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

By SumGuy 6 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 and local Hide Command Substitution Failures

A bare command substitution in an assignment actually does trigger set -e:

subshell_sub.sh
#!/bin/bash
set -e
echo "Starting"
result=$(false) # set -e catches this — script exits here
echo "Got result: $result"
Terminal window
$ bash subshell_sub.sh
Starting

So far so good. But the moment you slap a builtin in front of the assignment — local, export, declare, readonly — the rules flip:

local_trap.sh
#!/bin/bash
set -e
get_value() {
local result=$(false) # The error is SILENTLY swallowed
echo "This DOES run (the problem): [$result]"
}
get_value
echo "And so does this"
Terminal window
$ bash local_trap.sh
This DOES run (the problem): []
And so does this

Why? Because the exit status set -e checks is now the status of local itself — and local almost always succeeds. The failure of $(false) gets thrown away. This bites people constantly, because wrapping things in functions with local vars is good practice everywhere else.

The fix is to declare and assign on separate lines:

local_fixed.sh
#!/bin/bash
set -e
get_value() {
local result
result=$(false) # Now set -e sees the failure
echo "Never runs: [$result]"
}
get_value

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

pipe.sh
#!/bin/bash
set -e
echo "Starting"
false | cat # First command fails, but the pipeline "succeeds"
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 by default is the exit status of the last command. The last command is cat, which exits 0 (it happily passed nothing along). The failure of false upstream is invisible. So set -e doesn’t care.

(Watch out for the opposite trap too: false | grep something actually does stop the script — but only by accident, because grep itself exits non-zero on no match. Don’t rely on luck.)

The fix is set -o pipefail, which makes the pipeline return the status of the last command to exit non-zero:

pipe_fixed.sh
#!/bin/bash
set -e
set -o pipefail
echo "Starting"
false | cat
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

When you want to capture output and check whether the command failed, don’t lean on set -e to do it for you — handle it explicitly:

capture_fixed.sh
#!/bin/bash
set -euo pipefail
# Capture without aborting, then decide:
result=$(false || true)
if [ -z "$result" ]; then
echo "Command failed"
exit 1
fi

Or better, run the command in its own if so the failure is right there in front of you:

capture_better.sh
#!/bin/bash
set -euo pipefail
if ! result=$(some_command); then
echo "Command failed"
exit 1
fi
echo "Got: $result"

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'll show up above once verified.


Previous Post
Flowise vs Langflow: Build AI Pipelines Without Writing a Novel
Next Post
Bash Arithmetic Without bc

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts