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 Ignores Subshells
#!/bin/bashset -e
echo "Starting"(false) # Subshell! exit status is ignoredecho "This DOES run (the problem)"$ bash subshell.shStartingThis 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:
#!/bin/bashset -e
echo "Starting"result=$(false) # Command substitution, error ignoredecho "Got result: $result"$ bash subshell_sub.shStartingGot 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)
#!/bin/bashset -e
echo "Starting"false | grep something # First command fails, but...echo "This DOES run"$ bash pipe.shStartingThis DOES runWhy? 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:
#!/bin/bashset -eset -o pipefail
echo "Starting"false | grep somethingecho "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
For subshells:
#!/bin/bashset -euo pipefail
result=$(false) # This still won't trigger set -e# Fix it:result=$(false || true)if [ -z "$result" ]; then echo "Command failed" exit 1fiOr better, avoid the subshell:
#!/bin/bashset -euo pipefail
if ! false; then echo "Command failed" exit 1fiFor 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.