Strict mode, honestly
What set -e, set -u and pipefail catch, and the cases where they stay silent.
tar -xzf bo-strict.tar.gz, which creates bo-strict/. SHA-256: db10b03dd6bf9a2a17f6f81de8666e1be80a1ba6faa293ad424367ba8f032f8cMost 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:
#!/usr/bin/env bash# setup.sh: create the sample files this lesson's scripts read. Run it once in ~/bash-ops/strict.set -Eeuo pipefailprintf 'listen=8443\nlog_level=info\n' > app.confprintf 'old settings, keep me\n' > settings.oldcat > app.log << 'END'2026-09-27T08:00:01Z INFO backup started2026-09-27T08:00:04Z ERROR disk /srv/backup is 97% full2026-09-27T08:00:05Z INFO retrying on /srv/backup22026-09-27T08:00:09Z ERROR checksum mismatch in db.tar.gz2026-09-27T08:00:10Z INFO backup finished with errorsENDcat > clean.log << 'END'2026-09-27T09:00:01Z INFO backup started2026-09-27T09:00:07Z INFO backup finishedEND
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:
#!/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 failscd stagingrm -f -- *.oldecho "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:
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:
#!/usr/bin/env bash# clean-old-strict.sh: the same job, with errexit on.set -ecd stagingrm -f -- *.oldecho "cleanup done"
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:
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:
${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 !:
#!/usr/bin/env bash# tested.sh: failures that set -e deliberately lets through.set -eif false; then echo "not printed"; fiwhile false; do echo "not printed"; donefalse || 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! trueecho "3. still running after ! true"
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:
#!/usr/bin/env bash# deploy-conf.sh: copy app.conf into the release directory.set -edie() { echo "ERROR: $*" >&2; exit 1; }install_conf() {cp -- app.conf release/app.confecho "app.conf installed"}install_conf || die "could not install app.conf"echo "deploy finished"
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:
#!/usr/bin/env bash# deploy-conf-fixed.sh: the function checks its own critical command.set -edie() { echo "ERROR: $*" >&2; exit 1; }install_conf() {cp -- app.conf release/app.conf || return 1echo "app.conf installed"}install_conf || die "could not install app.conf"echo "deploy finished"
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:
#!/usr/bin/env bash# backup-note.sh: a job with an optional debug line.set -edebug() { [[ -n ${DEBUG:-} ]] && echo "debug: $*" >&2; }echo "backup written"debug "3 files, 12 KiB"echo "backup verified"
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:
#!/usr/bin/env bash# report-end.sh: every step succeeds; look at the last line.set -efailed_hosts=0echo "3 hosts checked"(( failed_hosts > 0 )) && echo "failed hosts: $failed_hosts"
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:
#!/usr/bin/env bash# local-mask.sh: the same assignment, written two ways.set -ecombined() {# shellcheck disable=SC2155 # unsafe on purpose: this is the form that hides the failurelocal token=$(cat missing-token.txt)echo "combined: token is [$token] and the script goes on"}split() {local tokentoken=$(cat missing-token.txt)echo "split: not printed"}combinedsplitecho "not printed either"
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:
#!/usr/bin/env bash# waiting.sh: list the files waiting in incoming/.set -ewaiting=$(cd incoming; printf '%s\n' *)printf 'waiting in incoming:\n%s\n' "$waiting"
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:
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:
#!/usr/bin/env bash# waiting-inline.sh: the substitution used directly as an argument.set -eshopt -s inherit_errexitprintf 'waiting in incoming: %s\n' "$(cd incoming; printf '%s ' *)"echo "still running"
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:
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:
#!/usr/bin/env bash# count-errors.sh LOGFILE: count the ERROR lines in a log.set -euo pipefaillog=${1:?usage: count-errors.sh LOGFILE}rc=0count=$(grep -c -- 'ERROR' "$log") || rc=$?# grep: 0 = matches, 1 = no match (a normal answer here), 2 = could not read the fileif (( rc > 1 )); thenecho "count-errors.sh: could not read $log" >&2exit 1fiecho "ERROR lines in $log: $count"
|| 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)).
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:
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:
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:
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:
#!/usr/bin/env bash# first-error.sh LOGFILE: print the first ERROR line of a log.set -euo pipefaillog=${1:?usage: first-error.sh LOGFILE}first=$(grep -- 'ERROR' "$log" | head -n 1)echo "first error: $first"
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.
#!/usr/bin/env bash# first-error-fixed.sh LOGFILE: print the first ERROR line of a log.set -euo pipefaillog=${1:?usage: first-error-fixed.sh LOGFILE}rc=0first=$(grep -m 1 -- 'ERROR' "$log") || rc=$?case $rc in0) echo "first error: $first" ;;1) echo "no ERROR lines in $log" ;;*) echo "first-error-fixed.sh: could not read $log" >&2; exit 1 ;;esac
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:
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.
#!/usr/bin/env bash# err-report.sh: say where a failure happened.set -Eeuo pipefailtrap 'echo "${0##*/}: line $LINENO: $BASH_COMMAND failed with status $?" >&2' ERRarchive_conf() {cp -- app.conf archive/app.conf}archive_confecho "not printed"
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:
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:
#!/usr/bin/env bash# strict-template.sh FILE DIR: copy FILE into DIR, with the header this course uses.set -Eeuo pipefailshopt -s inherit_errexittrap 'echo "${0##*/}: line $LINENO: $BASH_COMMAND failed with status $?" >&2' ERRdie() { 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".
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.
set -Eeuo pipefail) calls sync_files || die "sync failed". Inside sync_files, rsync fails and the next line runs echo "sync complete". What happens?||; nothing reports the failure to the caller.sync_files. Left of ||, errexit is ignored inside the function.echo, so || sees success. The function must check rsync itself (|| return 1).$? is replaced by every command; after the echo it is 0, and that is what || tests.first=$(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?PIPESTATUS in the lesson showed 141 for the writer and 0 for head.set -e and shopt -s inherit_errexit. Which line stops the script when curl fails?local, which succeeds, so the failure is hidden (SC2155).printf succeeds and its status is the one errexit sees, even with inherit_errexit.local, export returns its own status, so the substitution's failure is masked.