set -euo pipefail is three options: -e exits at the first failing command, -u makes an unset variable fatal, and pipefail lets any command in a pipeline fail it. The bash manual also lists four places where -e is defined to do nothing.
Short version: set -euo pipefail turns on three options. -e exits the script at the first failing command, -u makes an unset variable a fatal error, and pipefail lets any command in a pipeline fail the whole pipeline. The catch is that the bash manual itself lists four positions where -e is defined to do nothing at all.
Every command and every line of output below was recorded in one session on a real Debian VM, not retyped from memory. It closes a bash series after history expansion and $_.
Only -e decides when the script dies. -u and pipefail change what counts as a failure, and -e acts on the verdict.
root@ops-01:/srv/app# cat works.sh
#!/bin/bash
set -e
cat /etc/nosuchfile
echo "never printed"
root@ops-01:/srv/app# bash works.sh
cat: /etc/nosuchfile: No such file or directory
root@ops-01:/srv/app# echo $?
1
Notice what bash contributed: nothing. The only message came from cat, the exit status is cat's, and the last line never ran. Errexit is as silent when it declines to fire as when it fires.
A command in a condition cannot kill the script: the test of an if, the condition of a while or until, and every command in a && or || chain except the last one.
root@ops-01:/srv/app# cat inside-if.sh
#!/bin/bash
set -e
if cat /etc/nosuchfile; then echo "found it"; fi
echo "script is still alive"
root@ops-01:/srv/app# bash inside-if.sh
cat: /etc/nosuchfile: No such file or directory
script is still alive
A failing test is an answer, not an error: if grep -q pattern file has to be allowed to return 1. Only the command after the final && or || is exposed, so false && true survives while true && false dies. On a last line that bites the other way:
root@ops-01:/srv/app# cat last-line.sh
#!/bin/bash
set -e
echo "doing work"
false && echo "both ran"
root@ops-01:/srv/app# bash last-line.sh
doing work
root@ops-01:/srv/app# echo $?
1
Errexit never fired: false sat in an exempt position. But a script exits with the status of its last command, so CI reports a failed build for work that finished fine. Most engineers meet this the day somebody deletes the line under a condition && action.
Putting ! in front of a command moves it into the same exempt position.
root@ops-01:/srv/app# cat inverted.sh
#!/bin/bash
set -e
! cat /etc/nosuchfile
echo "still alive after !"
root@ops-01:/srv/app# bash inverted.sh
cat: /etc/nosuchfile: No such file or directory
still alive after !
The quiet hazard is assertions. ! grep -q pattern file looks like proof that something is absent, but grep returns 1 for no match and 2 for a real error such as an unreadable file. Inversion turns both into 0, so the assertion passes hardest when grep never read the file.
A pipeline's exit status is the rightmost command's status. Everything left of the final pipe can fail unnoticed.
root@ops-01:/srv/app# cat in-pipeline.sh
#!/bin/bash
set -e
cat /etc/nosuchfile | wc -l
echo "still alive"
root@ops-01:/srv/app# bash in-pipeline.sh
cat: /etc/nosuchfile: No such file or directory
0
still alive
wc succeeded, so the pipeline succeeded. That 0 is a real number from a healthy command and a lie about the world: it counted an empty stream. In a report it says "zero lines" and means "could not open the file". The verdicts do survive, somewhere you have to look on purpose.
root@ops-01:/srv/app# cat /etc/nosuchfile | wc -l
cat: /etc/nosuchfile: No such file or directory
0
root@ops-01:/srv/app# echo "${PIPESTATUS[@]}"
1 0
root@ops-01:/srv/app# echo "${PIPESTATUS[@]}"
0
1 0 reads left to right: cat failed, wc succeeded. Read it twice and the second answer is 0, because PIPESTATUS now describes the echo that read it, the same self-overwrite the $_ article covers. Copy it in one move with st=("${PIPESTATUS[@]}"). The cure here is pipefail, which comes with a bill of its own.
The status of $(...) survives only when the substitution is the whole right-hand side of a plain assignment. Hand it to another command and that command's status is judged instead.
root@ops-01:/srv/app# cat swallowed.sh
#!/bin/bash
set -e
echo "content: $(cat /etc/nosuchfile)"
echo "still alive"
root@ops-01:/srv/app# bash swallowed.sh
cat: /etc/nosuchfile: No such file or directory
content:
still alive
The command on that line was echo, and echo succeeded. Compare a bare assignment, the one shape errexit still covers:
root@ops-01:/srv/app# cat assign.sh
#!/bin/bash
set -e
version=$(cat /etc/nosuchfile)
echo "never printed"
root@ops-01:/srv/app# bash assign.sh
cat: /etc/nosuchfile: No such file or directory
root@ops-01:/srv/app# echo $?
1
The third shape surprises people who knew the first two: errexit is switched off inside the substitution, not merely bypassed around it.
root@ops-01:/srv/app# cat inside-subst.sh
#!/bin/bash
set -e
out=$(cat /etc/nosuchfile; echo "kept going inside")
echo "got: $out"
root@ops-01:/srv/app# bash inside-subst.sh
cat: /etc/nosuchfile: No such file or directory
got: kept going inside
Outside POSIX mode, bash clears -e in the subshell it spawns for a command substitution, so the failed cat did not stop the inner list. That list ended on a successful echo, so the substitution exited 0 and the assignment succeeded with a value unrelated to the file. shopt -s inherit_errexit, since bash 4.4, stops the clearing.
Two more traps, in one aside. local version=$(cat ...) keeps going, because local has its own exit status and it succeeded; declare first, assign second. And ((i++)) when i is 0 kills a script with no output: the expression evaluates to 0, and arithmetic returns 1 for zero.
This is the one that turns a silent failure into a deploy reporting success. A function called from an exempt position runs its entire body with errexit off. First, as a plain command:
root@ops-01:/srv/app# cat deploy-bare.sh
#!/bin/bash
set -e
deploy() {
rm /etc/nosuchfile
echo "deploy kept running"
}
deploy
echo "after deploy"
root@ops-01:/srv/app# bash deploy-bare.sh
rm: cannot remove '/etc/nosuchfile': No such file or directory
root@ops-01:/srv/app# echo $?
1
Byte for byte the same function, called from an if:
root@ops-01:/srv/app# cat deploy-in-if.sh
#!/bin/bash
set -e
deploy() {
rm /etc/nosuchfile
echo "deploy kept running"
}
if deploy; then echo "deploy reported success"; fi
root@ops-01:/srv/app# bash deploy-in-if.sh
rm: cannot remove '/etc/nosuchfile': No such file or directory
deploy kept running
deploy reported success
root@ops-01:/srv/app# echo $?
0
The body ran past the failure to the end. The function's status became its last command's status, echo succeeded, the if took the success branch, and the script exited 0. A deploy that removed nothing announced that it worked.
The manual is explicit on both halves: nothing in that body is affected by -e, and writing set -e inside the function does not help while it is called from that position. The exemption is transitive through the call tree, so wrapping work in functions and calling them from if quietly neutralises errexit. Red Hat's error handling walkthrough drops errexit for explicit checks over this exact case.
It turns a typo from a silent empty string into a stopped script. That is the whole argument, and one demo makes it.
root@ops-01:/srv/app# cat typo.sh
#!/bin/bash
src="/srv/app/data"
echo "would run: rm -rf $scr/cache"
root@ops-01:/srv/app# bash typo.sh
would run: rm -rf /cache
scr is not src. An unset variable expands to nothing, so the path changed from /srv/app/data/cache to /cache. The echo keeps the demo safe; the unguarded version is the Steam client bug that deleted home directories when its root variable came out empty.
root@ops-01:/srv/app# cat typo-guarded.sh
#!/bin/bash
set -u
src="/srv/app/data"
echo "would run: rm -rf $scr/cache"
root@ops-01:/srv/app# bash typo-guarded.sh
typo-guarded.sh: line 4: scr: unbound variable
Learn the shape of that message: script, line number, variable name, unbound variable. Named variables appear bare, positionals appear as $1: unbound variable. The escapes are parameter expansion, not exceptions to -u.
root@ops-01:/srv/app# bash defaults.sh
region: us-east-1
deploying to staging
root@ops-01:/srv/app# bash defaults.sh prod-eu
region: us-east-1
deploying to prod-eu
That script reads ${REGION:-us-east-1} and target="${1:-staging}". The colon matters: ${v-default} substitutes only when v is unset, ${v:-default} also when it is set but empty. Going the other way, ${var:?must be set} is fatal per variable with your own message. One exemption is built in: "$@" and arrays subscripted with @ or * are safe even when empty.
pipefail makes a pipeline return the rightmost nonzero status instead of the last command's. It does not stop the pipeline.
root@ops-01:/srv/app# cat with-pipefail.sh
#!/bin/bash
set -eo pipefail
cat /etc/nosuchfile | wc -l
echo "never printed"
root@ops-01:/srv/app# bash with-pipefail.sh
cat: /etc/nosuchfile: No such file or directory
0
root@ops-01:/srv/app# echo $?
1
wc still ran and still printed its 0. Every command in a pipeline runs in its own subshell and all run to completion; pipefail changes only the verdict at the end. The first bill it hands you looks like a bug and is not one.
root@ops-01:/srv/app# cat sigpipe.sh
#!/bin/bash
set -eo pipefail
seq 1 200000 | head -n 1
echo "never printed"
root@ops-01:/srv/app# bash sigpipe.sh
1
root@ops-01:/srv/app# echo $?
141
head printed the first line and exited, seq kept writing into a pipe with no reader, and the kernel delivered SIGPIPE. Bash reports a signal death as 128 plus the signal number, and 13 gives 141. Without pipefail, head's 0 wins and none of it shows. Where the trim is the point, write | head -n 1 || true. The second bill ends up in postmortems.
root@ops-01:/srv/app# cat count.sh
#!/bin/bash
set -eo pipefail
count=$(grep -c backupuser /etc/passwd)
echo "found $count"
root@ops-01:/srv/app# bash count.sh
root@ops-01:/srv/app# echo $?
1
No output at all. grep -c found no match, printed 0 into a variable belonging to a script that was already dead, and exited 1. Zero matches is not an error in the human sense but it is nonzero in the exit code sense, so errexit fired: exit codes do not reliably mean "something went wrong". The fix is count=$(grep -c backupuser /etc/passwd || true).
Never put -e in an interactive shell. It does exit on the first failing command, so a typo logs you out. Here it is in a sacrificial child shell with a marker prompt.
root@ops-01:/srv/app# bash --norc
root@ops-01:/srv/app# PS1='(strict) root@ops-01:\w# '
(strict) root@ops-01:/srv/app# set -e
(strict) root@ops-01:/srv/app# cat /etc/nosuchfile
cat: /etc/nosuchfile: No such file or directory
root@ops-01:/srv/app# echo $?
1
No goodbye and no exit line. The (strict) shell stopped existing and the parent prompt came back. -u interactively is gentler, but completion scripts legitimately probe unset variables, so it floods you.
Sourced files are the other hazard, because set flags are shell state and not file state: a library that runs set -e changes its caller for the life of that shell.
The honest counterargument, from Greg's Wiki BashFAQ/105: nonzero does not mean error, the exception list is long enough to memorise anyway, and the rules have drifted between bash versions. Sharper still, a team that believes -e protects them writes fewer explicit checks, and the four places above are where those checks would have lived. Keep the header anyway, treat it as a seatbelt and not as error handling, and comment every || true.
Errexit prints nothing of its own, so give it a voice. A trap on ERR runs before the shell exits, $? inside it is still the failing status, and $LINENO is the line that failed.
root@ops-01:/srv/app# cat errtrap.sh
#!/bin/bash
set -euo pipefail
trap 'echo "FAILED: exit $? at line $LINENO" >&2' ERR
cat /etc/nosuchfile
echo "never printed"
root@ops-01:/srv/app# bash errtrap.sh
cat: /etc/nosuchfile: No such file or directory
FAILED: exit 1 at line 4
root@ops-01:/srv/app# echo $?
1
That is the difference between a silent stop and a line number. Add set -E, also spelled set -o errtrace, so functions and subshells inherit the trap.
| You wrote | Under set -e | Why | |
|---|---|---|---|
| a plain failing command | exits, silently | the normal case | |
if cmd / while cmd | keeps going | condition position | |
cmd && other, cmd fails | keeps going | not the list's last command | |
a && b last line, a false | runs fine, exits 1 | list status becomes the script's | |
! cmd | keeps going | the status is inverted | |
| `bad \ | good` | keeps going | only the last command counts |
| `bad \ | good with pipefail` | exits after the pipeline | rightmost nonzero wins |
v=$(bad) | exits | the assignment carries the status | |
echo "$(bad)" | keeps going | echo's status wins | |
if myfunc | the body runs unprotected | function in an exempt context |
Verified on GNU bash 5.2.15 on Debian 12. The exception list is bash specific and the exact rules have shifted between versions, so check your own shell before leaning on an edge case.
Why did my script stop with no error message at all? That is set -e working. It prints nothing of its own, so the only message is whatever the failing command wrote, and a line like ((i++)) writes nothing. An ERR trap gets you a line number.
Why does set -e ignore the failing command in my if statement? By design. The manual exempts an if test, while and until conditions, every command in a && or || chain except the last, and anything inverted with !. The catch: a function called from any of those runs its whole body with errexit off.
What does exit code 141 mean? A command died on SIGPIPE, and bash reports a signal death as 128 plus the signal number, which for signal 13 is 141. The classic cause under pipefail is something | head, where head exits early and the producer's next write hits a closed pipe.
Is set -euo pipefail the same as three separate set lines? Identical: -e, -u, and -o taking pipefail as its argument. $- holds only single-letter flags, so it shows e and u but never pipefail, which has no letter form. Check that one with set -o.
More from the Field Manual, or work through the training tracks.