Why this lesson
set -e sounds like "stop on any failure". It is not. There are four places where a command can fail and the script carries on as if nothing happened. Real scripts have shipped broken releases through every one of them, so you learn them now, before you rely on -e.
What you need to know already: the header and set -e (6.1); && and || (1.7); what a pipe | does (1.7).
Three pieces of syntax first
The examples use three things you have not written yet. In one line each:
if CMD; then ...; fi- runCMD; if it exits 0, run the part afterthen.fi(if backwards) ends it.while CMD; do ...; donerepeats whileCMDkeeps succeeding. The command afterif/whileis the condition.- Command substitution
$(cmd)- runcmdand put its output right there, as text.today=$(date +%F)stores the date in a variable. - A function - a named group of commands:
f() { echo hi; }defines it,fruns it. Inside one,local xmakes a variable that exists only inside that function. (Functions get their own lesson, 6.15.)
Hole 1: anything in a condition
if grep -q pattern file; then ... # grep failing is the whole POINT here
while read -r line; do ... # read failing (end of file) ends the loop
grep -q searches quietly: no output, only the exit status (0 = found). If set -e fired on a failing condition, if would be useless - so bash switches -e off inside conditions. The price: a command buried in a condition can fail for a real reason (the file is missing) and you will never know.
Hole 2: anything followed by && or ||
important_command || echo "warning" # -e does not fire
With || you told bash what to do on failure, so the failure counts as "handled" and set -e stays quiet. That is why cmd || true is the standard way to say "this may fail, carry on" - and why a stray || echo hides a real failure.
Hole 3: every stage of a pipeline except the last
./build.sh | tee build.log # the exit status is TEE's
tee FILE copies its input to the screen and into FILE - a common way to keep a log. tee succeeds, so the pipeline succeeds, so set -e does nothing, even though the build failed. This is the one that ships broken releases.
set -o pipefail fixes it. Bash also keeps every stage's status in an array (a variable holding a list) called PIPESTATUS: ${PIPESTATUS[0]} is the first stage's.
Hole 4: local x=$(cmd) - the genuinely surprising one
f() {
local x=$(false) # false failed... but $? is 0 !
echo "status: $?" # prints 0
}
local is itself a command, a builtin (a command built into bash, not a separate program), and it succeeded. Its status replaces the one from $(false), so the failure vanishes and set -e sees success. The same happens with export x=$(cmd) and declare x=$(cmd).
The fix is to split it into two lines:
local x
x=$(cmd) # a plain assignment keeps cmd's status; set -e works
shellcheck - a tool that reads a script and points out likely bugs, each with a code number (6.22) - reports exactly this as SC2155.
So is set -e worth it?
Yes. It catches the common case: a command failing in a plain line-after-line script. Treat it as a safety net, not a guarantee. For anything important, check the command explicitly:
if ! do_the_thing; then # ! flips success and failure
echo "do_the_thing failed" >&2 # >&2: send this message to stderr
exit 1
fi
What you can now do
- Name the four places
set -edoes not fire, and show one of each. - Fix
local x=$(cmd)and a pipeline that hides a failure.