Strict mode, honestly

What set -e, set -u and pipefail catch, and the cases where they stay silent.

Beginner40 min · lesson 11 of 15
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (16 files, 2 KB): bo-strict.tar.gz. Unpack it with tar -xzf bo-strict.tar.gz, which creates bo-strict/. SHA-256: db10b03dd6bf9a2a17f6f81de8666e1be80a1ba6faa293ad424367ba8f032f8c

Most Bash scripts you will meet start with set -euo pipefail, often called strict mode. It makes a script stop on many failures it would otherwise ignore, and it has exceptions that surprise almost everyone: a script that relies on it blindly can report success after a step failed. You will run each rule as a tiny script and read its exit status: what -e, -u and pipefail catch, where -e is switched off, the failures it never sees, why | head can end a script with status 141, and how an ERR trap reports where a script failed. The examples run in ~/bash-ops/strict.

What set -e and set -u change

setup.sh creates the files the examples read: a config, a log with two errors, one with none, and a file to keep. Run it first, and chmod +x each script when you first run it, as the steps do:

setup.sh
#!/usr/bin/env bash
# setup.sh: create the sample files this lesson's scripts read. Run it once in ~/bash-ops/strict.
set -Eeuo pipefail
printf 'listen=8443\nlog_level=info\n' > app.conf
printf 'old settings, keep me\n' > settings.old
cat > app.log << 'END'
2026-09-27T08:00:01Z INFO backup started
2026-09-27T08:00:04Z ERROR disk /srv/backup is 97% full
2026-09-27T08:00:05Z INFO retrying on /srv/backup2
2026-09-27T08:00:09Z ERROR checksum mismatch in db.tar.gz
2026-09-27T08:00:10Z INFO backup finished with errors
END
cat > clean.log << 'END'
2026-09-27T09:00:01Z INFO backup started
2026-09-27T09:00:07Z INFO backup finished
END
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x setup.sh ./setup.sh ls -- app.conf *.log *.old
app.conf app.log clean.log settings.old

By default Bash runs every line of a script, whatever happened on the line before. This script is meant to delete the .old files a release left in staging/, but that directory was renamed:

clean-old.sh
#!/usr/bin/env bash
# clean-old.sh: delete the *.old files the last release left in staging/.
# shellcheck disable=SC2164 # unsafe on purpose: the lesson shows what happens when cd fails
cd staging
rm -f -- *.old
echo "cleanup done"

The # shellcheck disable line tells ShellCheck (lesson "Check your script") that the missing check after cd is deliberate. Run it next to a file you want to keep:

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x clean-old.sh ls -- *.old ./clean-old.sh echo "status: $?" ls -- *.old
settings.old ./clean-old.sh: line 4: cd: staging: No such file or directory cleanup done status: 0 ls: cannot access '*.old': No such file or directory

cd failed and printed an error, and Bash went on to the next line. rm ran in the directory the script started in and deleted settings.old. The script then printed "cleanup done" and exited 0, the status of its last command, so a scheduler would record a success.

set -e (long name errexit) changes this: when a command fails, the script exits at once with that command's status. The same script with set -e on its third line:

clean-old-strict.sh
#!/usr/bin/env bash
# clean-old-strict.sh: the same job, with errexit on.
set -e
cd staging
rm -f -- *.old
echo "cleanup done"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x clean-old-strict.sh printf 'old settings, keep me\n' > settings.old ./clean-old-strict.sh echo "status: $?" ls -- *.old
./clean-old-strict.sh: line 4: cd: staging: No such file or directory status: 1 settings.old

The script stopped at cd with status 1, rm never ran, and settings.old is still there. ShellCheck stays quiet about this cd because set -e is on.

set -u (nounset) handles a different mistake. Without it, a misspelt or unset variable expands to nothing and the command runs with a piece missing. The commands below only print the rm they would run:

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ bash -c 'backup_dir=/srv/backups; echo "would run: rm -rf ${backup_dri}/old"' bash -u -c 'backup_dir=/srv/backups; echo "would run: rm -rf ${backup_dri}/old"'
would run: rm -rf /old bash: line 1: backup_dri: unbound variable

Without -u the typo backup_dri turned the path into /old. With -u the shell stopped with "unbound variable" before running the command. -u only catches names that were never set: an empty value passes, which is why the lesson "Arrays and parameter expansion" used ${var:?} on paths given to rm. When a variable may legitimately be unset, say so with a default:

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ bash -u -c ' echo "days: ${1:-14}" excludes=() echo "${#excludes[@]} excludes: [${excludes[*]}]" for pattern in "${excludes[@]}"; do echo "exclude $pattern"; done echo "first argument: $1" '
days: 14 0 excludes: [] bash: line 6: $1: unbound variable

${1:-14} gave 14 because no argument was passed. An empty array is fine under -u in Bash 4.4 and later, including Ubuntu 26.04's 5.3: ${#excludes[@]} is 0 and the loop runs zero times. The bare $1 stopped the shell. In a script, write ${1:-default} for optional arguments and ${1:?usage: ...} for required ones.

Where set -e is switched off

-e is ignored wherever a command's status is being tested, because Bash assumes your code will handle it. That covers the condition of if, while and until, any command to the left of && or ||, and a command after !:

tested.sh
#!/usr/bin/env bash
# tested.sh: failures that set -e deliberately lets through.
set -e
if false; then echo "not printed"; fi
while false; do echo "not printed"; done
false || echo "1. false failed and || handled it"
false && echo "not printed"
echo "2. still running after a failure left of &&"
# shellcheck disable=SC2251 # on purpose: this line shows that ! switches errexit off
! true
echo "3. still running after ! true"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x tested.sh ./tested.sh echo "status: $?"
1. false failed and || handled it 2. still running after a failure left of && 3. still running after ! true status: 0

Every false failed and the script reached the end with status 0. That much is reasonable. The rule that catches people is this one: when a function is called in one of those places, -e is off for the whole body of the function, and for everything the function calls. (The Bash manual states it under set -e.) The most common form is a function followed by || die:

deploy-conf.sh
#!/usr/bin/env bash
# deploy-conf.sh: copy app.conf into the release directory.
set -e
die() { echo "ERROR: $*" >&2; exit 1; }
install_conf() {
cp -- app.conf release/app.conf
echo "app.conf installed"
}
install_conf || die "could not install app.conf"
echo "deploy finished"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x deploy-conf.sh ./deploy-conf.sh echo "status: $?"
cp: cannot create regular file 'release/app.conf': No such file or directory app.conf installed deploy finished status: 0

cp failed because release/ does not exist. Because install_conf was called left of ||, errexit did not stop it; the function went on, printed "app.conf installed", and returned the status of that echo, 0. die never ran, and the script exited 0 after reporting a deploy that did not happen.

The fix is to make a function that does real work check its own important commands and return a failure:

deploy-conf-fixed.sh
#!/usr/bin/env bash
# deploy-conf-fixed.sh: the function checks its own critical command.
set -e
die() { echo "ERROR: $*" >&2; exit 1; }
install_conf() {
cp -- app.conf release/app.conf || return 1
echo "app.conf installed"
}
install_conf || die "could not install app.conf"
echo "deploy finished"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x deploy-conf-fixed.sh ./deploy-conf-fixed.sh echo "status: $?" mkdir release ./deploy-conf-fixed.sh echo "status: $?"
cp: cannot create regular file 'release/app.conf': No such file or directory ERROR: could not install app.conf status: 1 app.conf installed deploy finished status: 0

Now the failed cp ends the function with status 1 and die runs. Once release/ exists, the script succeeds. The alternative is to call such functions as plain statements (install_conf on a line of its own), so -e stays on inside them.

A related trap is test && action as the last command of a function or script. When the test is false, -e ignores it (it is left of &&), but it is still the last command, so its status becomes the function's status:

backup-note.sh
#!/usr/bin/env bash
# backup-note.sh: a job with an optional debug line.
set -e
debug() { [[ -n ${DEBUG:-} ]] && echo "debug: $*" >&2; }
echo "backup written"
debug "3 files, 12 KiB"
echo "backup verified"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x backup-note.sh ./backup-note.sh echo "status: $?" DEBUG=1 ./backup-note.sh echo "status: $?"
backup written status: 1 backup written debug: 3 files, 12 KiB backup verified status: 0

Without DEBUG, debug returned 1. The call to debug is a plain statement, so -e stopped the script right there, silently, with status 1, and "backup verified" was never printed. With DEBUG=1 the test was true and everything ran. A script whose last line has this shape ends with status 1 even though all its work succeeded:

report-end.sh
#!/usr/bin/env bash
# report-end.sh: every step succeeds; look at the last line.
set -e
failed_hosts=0
echo "3 hosts checked"
(( failed_hosts > 0 )) && echo "failed hosts: $failed_hosts"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x report-end.sh ./report-end.sh echo "status: $?"
3 hosts checked status: 1

Write these as if [[ -n ${DEBUG:-} ]]; then ...; fi: an if whose condition is false has status 0.

Failures that never reach set -e

Some failures are hidden because another command's status takes their place. The first comes from local (and export and declare), which "Functions, local and logging" warned about:

local-mask.sh
#!/usr/bin/env bash
# local-mask.sh: the same assignment, written two ways.
set -e
combined() {
# shellcheck disable=SC2155 # unsafe on purpose: this is the form that hides the failure
local token=$(cat missing-token.txt)
echo "combined: token is [$token] and the script goes on"
}
split() {
local token
token=$(cat missing-token.txt)
echo "split: not printed"
}
combined
split
echo "not printed either"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x local-mask.sh ./local-mask.sh echo "status: $?"
cat: missing-token.txt: No such file or directory combined: token is [] and the script goes on cat: missing-token.txt: No such file or directory status: 1

In combined, cat failed, but the status of local token=$(...) is the status of local, which succeeded in creating an empty variable, so the function carried on. In split, local token comes first, and token=$(...) is a plain assignment whose status is the status of the command inside $( ): -e stopped the script with 1. ShellCheck reports the combined form as SC2155, which is why its line carries a directive here.

The second comes from command substitution itself. Commands inside $( ) run in a subshell, and Bash (outside POSIX mode) turns -e off in that subshell. A failure in the middle of a substitution is ignored; only the last command's status comes back:

waiting.sh
#!/usr/bin/env bash
# waiting.sh: list the files waiting in incoming/.
set -e
waiting=$(cd incoming; printf '%s\n' *)
printf 'waiting in incoming:\n%s\n' "$waiting"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x waiting.sh ./waiting.sh echo "status: $?"
./waiting.sh: line 4: cd: incoming: No such file or directory waiting in incoming: app.conf app.log … waiting.sh status: 0
# Your listing shows the files you have created so far.

cd incoming failed inside the substitution, the subshell went on, and printf listed the current directory. The report says the script's own files are waiting in a directory that does not exist, with status 0. The shell option inherit_errexit (Bash 4.4 and later) keeps -e on inside substitutions. bash -O turns a shopt option on from the command line, so the same file can be tested with it:

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ bash -O inherit_errexit ./waiting.sh echo "status: $?"
./waiting.sh: line 4: cd: incoming: No such file or directory status: 1

The subshell now stopped at cd, the assignment failed, and the script exited 1. inherit_errexit has a limit, though: it only helps when the substitution's status reaches -e, as in a plain assignment. When the substitution is an argument of another command, that command's status is the one that counts:

waiting-inline.sh
#!/usr/bin/env bash
# waiting-inline.sh: the substitution used directly as an argument.
set -e
shopt -s inherit_errexit
printf 'waiting in incoming: %s\n' "$(cd incoming; printf '%s ' *)"
echo "still running"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x waiting-inline.sh ./waiting-inline.sh echo "status: $?"
./waiting-inline.sh: line 5: cd: incoming: No such file or directory waiting in incoming: still running status: 0

The substitution stopped early and produced nothing, printf printed an empty list and succeeded, and the script went on with status 0. The rule that works everywhere: assign a substitution to a variable on its own line, then use the variable.

The opposite problem is a command that fails on purpose. grep exits 1 when it finds no match, which is often a normal answer, and 2 when it cannot read the file. The usual quick fix, || true, throws away both:

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ count=$(grep -c ERROR missing.log || true) echo "count: [$count]"
grep: missing.log: No such file or directory count: []

The log file does not exist, yet count is simply empty and nothing stopped. That form is unsafe for anything you check. Keep the status in a variable and decide which values are acceptable:

count-errors.sh
#!/usr/bin/env bash
# count-errors.sh LOGFILE: count the ERROR lines in a log.
set -euo pipefail
log=${1:?usage: count-errors.sh LOGFILE}
rc=0
count=$(grep -c -- 'ERROR' "$log") || rc=$?
# grep: 0 = matches, 1 = no match (a normal answer here), 2 = could not read the file
if (( rc > 1 )); then
echo "count-errors.sh: could not read $log" >&2
exit 1
fi
echo "ERROR lines in $log: $count"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x count-errors.sh ./count-errors.sh app.log echo "status: $?" ./count-errors.sh clean.log echo "status: $?" ./count-errors.sh missing.log echo "status: $?"
ERROR lines in app.log: 2 status: 0 ERROR lines in clean.log: 0 status: 0 grep: missing.log: No such file or directory count-errors.sh: could not read missing.log status: 1

|| rc=$? puts the assignment in a tested context, so -e does not stop the script, and records the status. No matches (status 1, count 0) is accepted; a missing file (2) stops the script with its own message. The same care applies to (( n++ )) when n is 0, which has status 1 ("Decisions: if, case and guard clauses"); this course counts with n=$((n + 1)).

A command fails while set -e is on. Does the script stop?
A command returns a non-zero status
where it sits decides what happens
a plain statement
Yes: the script exits with that status
the next line never runs
in an if/while/until condition, left of && or ||, after !
No: the status is an answer
your code has to act on it
anywhere inside a function called from one of those places
No: the whole body runs unchecked
f || die never reaches die
last command is test && action
Not there, but it becomes the status
the function or script returns 1
inside local x=$(...), or a $(...) used as an argument
No: another command's status wins
assign on its own line, then use the variable
set -e acts only on a status that reaches it; in the last four cases the failing status never does.

pipefail, SIGPIPE and PIPESTATUS

A pipeline's status is normally the status of its last command; the Linux essentials lesson "Pipes, redirection and exit status" showed that, and met pipefail and PIPESTATUS briefly. In a script it looks like this, with a typo in a path:

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ grep PermitRootLogin sshd_confg | tail -n 1 echo "status: $?" ( set -o pipefail grep PermitRootLogin sshd_confg | tail -n 1 echo "status: $?" )
grep: sshd_confg: No such file or directory status: 0 grep: sshd_confg: No such file or directory status: 2

Without pipefail, tail succeeded on empty input and the pipeline reported 0, as if the check had run. With set -o pipefail, the pipeline's status is that of the last command that failed, here grep's 2. The parentheses run those lines in a subshell, so pipefail does not stay on in your terminal. To see every stage's status, read the PIPESTATUS array (Bash only) straight after the pipeline:

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ grep PermitRootLogin sshd_confg | tail -n 1 echo "${PIPESTATUS[@]}" echo "${PIPESTATUS[@]}"
grep: sshd_confg: No such file or directory 2 0 0

The first echo shows grep's 2 and tail's 0. The second shows 0, because PIPESTATUS describes the last pipeline, and the first echo was one: copy it with status=("${PIPESTATUS[@]}") if you need it later.

pipefail has one well-known side effect. head -n 1 exits after the first line. If the command before it is still writing, its next write goes to a pipe with no reader, and the kernel sends it SIGPIPE (signal 13): it dies with status 128 + 13 = 141. Whether that happens depends on how much there is to write:

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ ( set -o pipefail printf 'a\nb\nc\n' | head -n 1 echo "status: $?, PIPESTATUS: ${PIPESTATUS[*]}" seq 1 200000 | head -n 1 echo "status: $?, PIPESTATUS: ${PIPESTATUS[*]}" )
a status: 0, PIPESTATUS: 0 0 1 status: 141, PIPESTATUS: 141 0

printf wrote its three short lines into the pipe in one go, before head exited. seq had about 1.3 MB to write, far more than the pipe's buffer (16 pages, 64 KiB on this machine; see pipe(7)), so it was still writing when head left: 141 from seq, 0 from head. A script can pass its tests with a small log and fail in production with a big one:

first-error.sh
#!/usr/bin/env bash
# first-error.sh LOGFILE: print the first ERROR line of a log.
set -euo pipefail
log=${1:?usage: first-error.sh LOGFILE}
first=$(grep -- 'ERROR' "$log" | head -n 1)
echo "first error: $first"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ seq -f 'ERROR check %g failed' 1 200000 > big.log wc -l big.log head -n 2 big.log
200000 big.log ERROR check 1 failed ERROR check 2 failed
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x first-error.sh ./first-error.sh app.log echo "status: $?" ./first-error.sh big.log echo "status: $?"
first error: 2026-09-27T08:00:04Z ERROR disk /srv/backup is 97% full status: 0 status: 141

On the five-line log the script worked. On the big one, grep was killed by SIGPIPE, pipefail made the substitution fail with 141, and -e ended the script with no message. The best fix is a command that stops by itself, so there is no pipe: grep -m 1 stops after the first match.

first-error-fixed.sh
#!/usr/bin/env bash
# first-error-fixed.sh LOGFILE: print the first ERROR line of a log.
set -euo pipefail
log=${1:?usage: first-error-fixed.sh LOGFILE}
rc=0
first=$(grep -m 1 -- 'ERROR' "$log") || rc=$?
case $rc in
0) echo "first error: $first" ;;
1) echo "no ERROR lines in $log" ;;
*) echo "first-error-fixed.sh: could not read $log" >&2; exit 1 ;;
esac
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x first-error-fixed.sh ./first-error-fixed.sh big.log echo "status: $?" ./first-error-fixed.sh clean.log echo "status: $?" ./first-error-fixed.sh missing.log echo "status: $?"
first error: ERROR check 1 failed status: 0 no ERROR lines in clean.log status: 0 grep: missing.log: No such file or directory first-error-fixed.sh: could not read missing.log status: 1

It handles the status as count-errors.sh did. The second fix is a reader that reads to the end, such as sed -n 1p; it costs the time to read everything, so it suits small outputs:

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ ( set -o pipefail seq 1 200000 | sed -n 1p echo "status: $?" )
1 status: 0

The third is to keep head and check PIPESTATUS yourself, accepting 141 from the first stage only. ShellCheck flags local x=$(...) and a bare !, but not f || die, a last-line &&, a substitution used as an argument or a pipe into head: those show only when the script runs.

The ERR trap and a header you can trust

When -e stops a script, it prints nothing about where. An ERR trap fixes that: trap 'commands' ERR runs the commands whenever a command fails in a place where -e would act. The next lesson covers traps in general.

err-report.sh
#!/usr/bin/env bash
# err-report.sh: say where a failure happened.
set -Eeuo pipefail
trap 'echo "${0##*/}: line $LINENO: $BASH_COMMAND failed with status $?" >&2' ERR
archive_conf() {
cp -- app.conf archive/app.conf
}
archive_conf
echo "not printed"
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x err-report.sh ./err-report.sh echo "status: $?"
cp: cannot create regular file 'archive/app.conf': No such file or directory err-report.sh: line 6: cp -- app.conf archive/app.conf failed with status 1 status: 1

The trap text is in single quotes so that $LINENO (the current line), $BASH_COMMAND (the command that failed) and $? are expanded when the trap runs, not when it is set. ${0##*/} is the script's name without its directory. The failing cp was inside a function, and the E in -Eeuo (errtrace) is what makes the trap apply inside functions and subshells. Without it:

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ sed 's/-Eeuo/-euo/' err-report.sh > err-report-noE.sh bash err-report-noE.sh echo "status: $?"
cp: cannot create regular file 'archive/app.conf': No such file or directory status: 1

The script still stopped with 1, but the trap stayed silent. ERR follows the same rules as -e, so it does not fire in a tested context such as install_conf || die either.

Here is the header this course uses, with the habits that make it trustworthy, in a small script that copies a file into a directory:

strict-template.sh
#!/usr/bin/env bash
# strict-template.sh FILE DIR: copy FILE into DIR, with the header this course uses.
set -Eeuo pipefail
shopt -s inherit_errexit
trap 'echo "${0##*/}: line $LINENO: $BASH_COMMAND failed with status $?" >&2' ERR
die() { echo "${0##*/}: $*" >&2; exit 1; }
debug() {
if [[ -n ${DEBUG:-} ]]; then echo "debug: $*" >&2; fi
}
install_file() {
local src=$1 dest=$2
[[ -f $src ]] || die "no such file: $src"
cp -- "$src" "$dest/" || die "could not copy $src to $dest"
debug "copied $src to $dest"
}
(( $# == 2 )) || { echo "usage: ${0##*/} FILE DIR" >&2; exit 2; }
install_file "$1" "$2"
echo "installed $1 in $2"

The three lines after the comment are the header: -E for the trap, -e, -u, pipefail, and inherit_errexit for substitutions assigned to variables. The rest assumes the header will miss some failures: install_file is called as a plain statement, its critical commands have explicit || die checks that say what failed, debug uses if, and a wrong number of arguments exits 2, as in "Command-line options".

deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ chmod +x strict-template.sh ./strict-template.sh app.conf release echo "status: $?" DEBUG=1 ./strict-template.sh app.conf release echo "status: $?"
installed app.conf in release status: 0 debug: copied app.conf to release installed app.conf in release status: 0
deploy@web01:~/bash-ops/strict · Ubuntu 26.04 LTS
$ ./strict-template.sh app.conf archive echo "status: $?" ./strict-template.sh missing.conf release echo "status: $?" ./strict-template.sh app.conf echo "status: $?"
cp: cannot create regular file 'archive/': Not a directory strict-template.sh: could not copy app.conf to archive status: 1 strict-template.sh: no such file: missing.conf status: 1 usage: strict-template.sh FILE DIR status: 2

Each failure names its cause. The ERR trap added nothing for the cp failure, because cp sits left of ||. That is the right split of work: explicit checks with an actionable message wherever a failure matters, and strict mode as the net for the rest. Strict mode lives in one Bash process; it does not reach a script you call or a command sent with ssh, so each script carries its own header. Keep it out of files you source into your interactive shell, where set -e would close the terminal at the first failed command.

Try this

Copy strict-template.sh and app.conf into a new directory, create an empty release/ there, and break the copy on purpose. Predict each result before you run it.

1. Change the last-but-one line to install_file "$1" "$2" || die "install failed" and remove || die "could not copy $src to $dest" from the cp line. Run it with app.conf no-such-dir, then echo "status: $?": expect cp's error, then installed app.conf in no-such-dir and status 0. Explain why neither -e nor the ERR trap stopped it.

2. Put the || die back on the cp line and run the same command: expect could not copy app.conf to no-such-dir and status 1.

3. Now change the body of debug to [[ -n ${DEBUG:-} ]] && echo "debug: $*" >&2 and run it with app.conf release and no DEBUG: expect install failed and status 1, although ls release shows that app.conf was copied. The failure is the false test at the end of debug, passed up through install_file to the || die. Restore the if and the plain call.

Takeaway

Use set -Eeuo pipefail and inherit_errexit as a safety net, not as your error handling: call functions that do work as plain statements, check each command whose failure matters with an explicit || die "what failed", assign substitutions on their own line, and keep || true away from anything whose failure you need to know about.

Quick check
01A strict script (set -Eeuo pipefail) calls sync_files || die "sync failed". Inside sync_files, rsync fails and the next line runs echo "sync complete". What happens?
Incorrect — Errexit is off for the whole function body when the function is called left of ||; nothing reports the failure to the caller.
Incorrect — That would happen for a plain call sync_files. Left of ||, errexit is ignored inside the function.
Correct — The function's status is that of its last command, the echo, so || sees success. The function must check rsync itself (|| return 1).
Incorrect — $? is replaced by every command; after the echo it is 0, and that is what || tests.
02first=$(grep ERROR app.log | head -n 1) works on your laptop, but on a busy server the strict script (set -euo pipefail) stops with status 141 and prints nothing. Why?
Incorrect — grep has no such limit. 141 is 128 + 13, the status of a process killed by signal 13, SIGPIPE.
Correct — With a large output grep is still writing when head exits; its next write gets SIGPIPE (141), and pipefail makes that the pipeline's status.
Incorrect — head exits 0 here; PIPESTATUS in the lesson showed 141 for the writer and 0 for head.
Incorrect — -u fires only on names that were never set, and it prints "unbound variable"; an empty value does not trigger it.
03A script has set -e and shopt -s inherit_errexit. Which line stops the script when curl fails?
Incorrect — The line's status is the status of local, which succeeds, so the failure is hidden (SC2155).
Incorrect — The substitution is an argument; printf succeeds and its status is the one errexit sees, even with inherit_errexit.
Incorrect — Like local, export returns its own status, so the substitution's failure is masked.
Correct — A plain assignment's status is the status of the substitution, so errexit sees curl's failure.

Related