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:
#!/bin/bashset -e
echo "Starting"false # This exits with status 1echo "This never runs"Run it:
$ bash basic.shStartingThe 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:
#!/bin/bashset -e
echo "Starting"result=$(false) # set -e catches this — script exits hereecho "Got result: $result"$ bash subshell_sub.shStartingSo far so good. But the moment you slap a builtin in front of the assignment — local, export, declare, readonly — the rules flip:
#!/bin/bashset -e
get_value() { local result=$(false) # The error is SILENTLY swallowed echo "This DOES run (the problem): [$result]"}get_valueecho "And so does this"$ bash local_trap.shThis DOES run (the problem): []And so does thisWhy? 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:
#!/bin/bashset -e
get_value() { local result result=$(false) # Now set -e sees the failure echo "Never runs: [$result]"}get_valueGotcha 2: set -e Ignores Pipes (Unless You Use pipefail)
#!/bin/bashset -e
echo "Starting"false | cat # First command fails, but the pipeline "succeeds"echo "This DOES run"$ bash pipe.shStartingThis DOES runWhy? 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:
#!/bin/bashset -eset -o pipefail
echo "Starting"false | catecho "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
#!/bin/bashset -e
echo "Starting"if false; then echo "In the if"fiecho "This DOES run"$ bash conditional.shStartingThis DOES runInside 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:
#!/bin/bashset -e
while false; do echo "Never runs"doneecho "This DOES run"The while loop successfully completes (it just doesn’t iterate). No error.
Gotcha 4: set -e Ignores Negated Commands
#!/bin/bashset -e
echo "Starting"! false # Negation inverts the exit codeecho "This DOES run"$ bash negated.shStartingThis 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:
#!/bin/bashset -euo pipefail
# ... rest of scriptBreakdown:
set -e— exit on error (even with limitations)set -u— error on undefined variablesset -o pipefail— pipe failures cause exit
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:
#!/bin/bashset -euo pipefail
# Capture without aborting, then decide:result=$(false || true)if [ -z "$result" ]; then echo "Command failed" exit 1fiOr better, run the command in its own if so the failure is right there in front of you:
#!/bin/bashset -euo pipefail
if ! result=$(some_command); then echo "Command failed" exit 1fiecho "Got: $result"For conditionals, be explicit:
#!/bin/bashset -euo pipefail
# Instead of:# if some_command; then# ...# fi
# Do:some_command || exit 1if [ $? -eq 0 ]; then echo "Command succeeded"fiThe Trap Alternative
For really critical scripts, use trap:
#!/bin/bashset -u
trap 'echo "Error on line $LINENO"; exit 1' ERR
echo "Starting"false # This now triggers the trapecho "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
#!/bin/bashset -euo pipefailtrap '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:
$ head -5 your_script.sh#!/bin/bashset -euo pipefailtrap 'echo "Error: line $LINENO"; exit 1' ERRThat’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.