Bash Strict Mode: `set -euo pipefail` Explained

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
Applies to Linux distributions with Bash installed
Privilege Normal user; no root required for the examples
Scope Bash strict mode semantics: set -e, set -u, pipefail, errexit exceptions, inherit_errexit, SIGPIPE, functions, positional parameters, and a strict-mode template. Does not cover full trap/ERR handling or set -E errtrace details.
Related guides Shell scripting course hub
Short on time?Beta Get the gist of this 13-minute read in seconds, then ask follow-up questions

set -euo pipefail enables three independent Bash safeguards: exit on many unhandled command failures, reject unset variables, and make a pipeline return failure when one of its commands fails. Bash does not have an official "strict mode," though, and each option has exceptions worth understanding before you add the line to every script.

pipefail is not part of portable POSIX sh. Use a Bash shebang (#!/usr/bin/env bash or #!/bin/bash) when your script depends on Bash features such as pipefail and shopt.


What Does set -euo pipefail Mean?

There is no strict_mode switch in Bash. Blog posts and style guides lump three independent options together:

Option Short form What it changes
errexit set -e Exit when an eligible command returns non-zero
nounset set -u Treat expansion of unset variables as an error
pipefail set -o pipefail Pipeline exit status reflects the rightmost failing command

The combined form is shorthand for three separate settings:

bash
set -euo pipefail

expands to:

bash
set -e          # errexit on
set -u          # nounset on
set -o pipefail # pipefail on

Use + instead of - to turn an option off: set +e, set +u, and set +o pipefail.

That preamble is a useful baseline for many scripts, not a guarantee of production-grade error handling. The sections below cover where each flag actually bites and where it quietly does nothing.


set -e: Exit on Unhandled Command Failures

set -e (errexit) tells Bash to exit when an eligible command or compound command returns a non-zero status. The important word is eligible: Bash deliberately ignores non-zero statuses in several control-flow contexts.

Failure means any exit status other than zero. The Bash exit codes article covers how $? records that status; errexit acts on the status of the command that just finished, not on a later line.

With errexit enabled, a bare failing command stops the script:

bash
set -e
false
echo "not reached"

The echo never runs and the shell exits with status 1.

Where set -e Does Not Exit

Errexit is not "exit on every non-zero status." Treating it like try/except in another language causes most strict-mode surprises.

Context Errexit behavior
Command in if, while, or until test Non-zero status does not exit
Command in && or || list Exempt except the command after the final && or ||
Command after ! Negated test; non-zero may be expected
Command in a pipeline, except the last Its individual non-zero status does not trigger errexit; pipefail can make the pipeline itself return non-zero
After set +e Disabled until set -e runs again

A grep -q miss returns status 1, which is normal control flow inside a conditional:

bash
set -e
if grep -q '^missing$' /etc/hosts; then
  echo "found"
else
  echo "no match — script continues"
fi

The same grep as a standalone command aborts the script:

bash
set -e
grep -q '^missing$' /etc/hosts
echo "not reached"

Commands in && and || lists are exempt from errexit except the command that follows the final && or || in that list:

bash
set -e
false && echo "skipped"
echo "after &&"
true || echo "skipped"
if ! false; then echo "negation works"; fi

false on the left of && does not exit because Bash is evaluating short-circuit logic, not treating the failure as fatal.

A while or until test uses the same exemption. When grep finds nothing, the loop ends and the script continues:

bash
set -e
while grep -q 'NOTFOUND' /etc/hosts; do
  echo "loop body"
done
echo "grep returned non-zero, script continues"

The ((count++)) Trap

Another classic errexit surprise is post-increment in arithmetic:

bash
set -e
count=0
((count++))
echo "not reached"

((count++)) evaluates to the old value (0). Bash arithmetic commands return status 1 when the expression evaluates to zero. Under set -e, that can terminate the script even though the increment itself succeeded.

For a counter starting at zero, ((++count)) avoids this particular first-increment failure. If the arithmetic result can legitimately be zero, use an assignment such as count=$((count + 1)) when you do not want the arithmetic value to determine command success.


set -u: Catch Unset Variables

set -u (nounset) turns unset parameter expansions into errors. That catches typos like $LOG_DIR when you meant $LOGDIR.

Optional Variables and Defaults

Optional values need a safe default or an explicit check. Bash parameter expansion provides forms that work under nounset:

bash
set -u
echo "${OPTIONAL_VAR:-default-value}"

The :- operator supplies a default without referencing an unset name bare.

A bare unset reference still fails:

bash
set -u
echo "$UNSET_VAR"
output
bash: UNSET_VAR: unbound variable

Missing Positional Parameters ($1, $2)

A common real-world nounset failure is reading $1 when no argument was passed:

bash
set -u
name=$1

Running that script without arguments triggers an unbound-variable error on $1.

Require arguments explicitly:

bash
set -u
if (( $# < 1 )); then
  echo "Usage: $0 NAME" >&2
  exit 2
fi
name=$1

For optional arguments, use a default:

bash
set -u
name=${1:-default}

set -o pipefail: Catch Pipeline Failures

By default, a pipeline's exit status is the status of the last command, even when an earlier stage failed. The Bash pipes guide covers PIPESTATUS and default pipeline rules; pipefail changes which status $? reports.

Without pipefail, the last command can mask an earlier failure:

bash
set -eu
false | true
echo "reached — errexit did not fire"

With pipefail and errexit together, the pipeline status is non-zero and the script stops:

bash
set -euo pipefail
false | true
echo "not reached"

The cmd | head SIGPIPE Trap

pipefail can turn an intentional early-close pipeline into a non-zero result because the producer receives SIGPIPE when the consumer stops reading. When you need per-stage status after a pipeline, read ${PIPESTATUS[*]} immediately; $? only keeps the overall pipeline status.

A producer that writes indefinitely makes the effect obvious:

bash
set -euo pipefail
set +e
yes | head -n 1 >/dev/null
printf 'pipeline=%d PIPESTATUS=(%s)\n' "$?" "${PIPESTATUS[*]}"
set -e
yes | head -n 1 >/dev/null
echo "not reached under errexit"
output
pipeline=141 PIPESTATUS=(141 0)

Status 141 is 128 + 13 (SIGPIPE). With pipefail and set -e restored, the second pipeline aborts the script before the final echo.

When an early-closing consumer such as head is normal for your pipeline, handle it in an if test, inspect ${PIPESTATUS[*]}, or temporarily disable errexit for that one line.


Command Substitution and inherit_errexit

In Bash, command substitution $(...) normally runs with errexit disabled inside the subshell unless inherit_errexit is on. A failing command inside $() can continue to the next line in that subshell instead of stopping it.

See Bash command substitution for the general $() model.

Save this reproducible script as /tmp/bash-strict-mode-lab/inherit-errexit.sh to compare both modes in one run:

bash
mkdir -p /tmp/bash-strict-mode-lab

Write, mark executable, and run a script that toggles inherit_errexit between two $(demo) calls:

bash
cat > /tmp/bash-strict-mode-lab/inherit-errexit.sh << 'EOF'
#!/usr/bin/env bash
set -e

demo() {
    false
    echo "continued inside command substitution"
}

echo "=== without inherit_errexit ==="
shopt -u inherit_errexit 2>/dev/null || true
result=$(demo)
printf 'result: %s\n' "$result"

echo "=== with inherit_errexit ==="
shopt -s inherit_errexit
result=$(demo)
echo "this line is not reached"
EOF
chmod +x /tmp/bash-strict-mode-lab/inherit-errexit.sh
/tmp/bash-strict-mode-lab/inherit-errexit.sh; echo "exit: $?"

Sample output:

output
=== without inherit_errexit ===
result: continued inside command substitution
=== with inherit_errexit ===
exit: 1

Without inherit_errexit, false does not terminate the substitution, so echo runs and the function succeeds. With inherit_errexit, execution stops at false inside $().

This does not mean every failed command substitution is hidden from the parent shell. For example, result=$(false) returns the status of false, so a parent shell running with set -e can still exit. inherit_errexit matters when multiple commands run inside $(): it determines whether an earlier failure stops the substitution or execution continues to a later command that may return success.

Enable inherit_errexit when you intentionally want failures inside command substitutions to follow the caller's errexit setting.

Don't combine local with a command substitution you need to check

local, declare, and export can mask the exit status of a command substitution because the builtin's successful status becomes the status of the command:

bash
set -e
myfunc() {
    local result=$(false)
    echo "still running"
}
myfunc
echo "after myfunc"

myfunc completes and the caller continues even though false ran inside $().

Split the assignment:

bash
set -e
myfunc() {
    local result
    result=$(false)
    echo "still running"
}
myfunc

Now errexit stops at result=$(false).


Functions Can Change set -e Behavior

When a function is invoked as the test of an if, errexit is suspended inside the function body. A failing command can fall through, and the function's return status may come from a later successful command:

bash
set -e
run_check() {
  grep -q '^missing$' /etc/hosts
  echo "inside function after grep"
}
if run_check; then
  echo "function returned 0"
else
  echo "function returned non-zero"
fi
output
inside function after grep
function returned 0

grep failed, but echo returned 0, so the if saw success. If the command's failure must determine the function result, check it explicitly instead of relying on set -e inside a function used as a conditional:

bash
run_check() {
  grep -q '^missing$' /etc/hosts || return
  echo "inside function after grep"
}

Or branch on the result:

bash
run_check() {
  if ! grep -q '^missing$' /etc/hosts; then
    return 1
  fi
  echo "check succeeded"
}

set -e inside a function is not function-local. Shell options changed inside a function remain changed after it returns because functions execute in the current shell context.

Sourced files (source or .) run in the current shell too. The Bash source vs execute article contrasts sourcing with running a script in a child process. A sourced library should generally avoid changing the caller's shell options. If it must change them, document that behavior or save and restore the previous option state.


How to Handle Expected Failures

When a non-zero status is expected, structure the command as control flow instead of turning errexit off for a whole block.

The clearest pattern is if or if !. The Bash if, elif, else lesson uses the same branching style:

bash
set -e
if ! grep -q '^missing$' /etc/hosts; then
  echo "expected miss"
fi
echo "script continues"

if ! cmd is useful when any non-zero status means the same thing to your script. If different non-zero statuses have different meanings, preserve and inspect the exit status instead. For grep, status 1 means no match, but status 2 (or higher) means an actual error such as a missing file; if ! grep puts both outcomes in the same branch.

|| true intentionally discards every non-zero status. if ! cmd makes the expected failure branch explicit, but use an ordinary if and inspect $? when different failure statuses need different handling.

A set +e / set -e pair around one command also works, yet it is easier to misread than an if test. Reserve broad set +e regions for legacy code you are refactoring.


What Strict Mode Still Does Not Catch

set -euo pipefail is not comprehensive error propagation. Explicit checks still matter for expected failures, cleanup, and operator-facing messages.

Failures inside process substitution (<(...)) are not automatically propagated to the parent command:

bash
set -euo pipefail
cat <(false)
echo "after cat — errexit did not fire"

false failed in the process substitution, but cat read empty input and returned 0. The parent script continues.

Errexit is a safety net for commands you truly expect to succeed. It does not replace documenting which failures are normal or checking statuses you care about.


Should Strict Mode Change IFS?

Some older strict-mode recipes also set IFS=$'\n\t'. That is not part of set -euo pipefail and changes how Bash performs word splitting.

Prefer proper quoting and arrays. Change IFS only when your script intentionally needs different field splitting, ideally in the smallest possible scope.


A useful starting header:

bash
#!/usr/bin/env bash
set -euo pipefail

# Optional on Bash 4.4+: make command substitutions inherit -e
shopt -s inherit_errexit

inherit_errexit was added in Bash 4.4. Add that line only when you need command substitutions to honor errexit; Bash 4.3 and earlier keep their normal $() behavior without it.

Pair the template with quoted expansions, if ! for expected failures, and trap cleanup on EXIT when scripts create temp files. set -E (errtrace) is separate from set -e; it controls whether ERR traps inherit into functions and subshells.


Common Bash Strict-Mode Pitfalls

Code / pattern Surprise
if grep -q … set -e does not exit on non-zero test status
grep -q … (bare) Non-zero match status aborts the script
cmd1 | cmd2 without pipefail Last stage can hide earlier failure
yes | head (with pipefail) SIGPIPE (141) can abort under set -e
((count++)) from zero Arithmetic status 1 can exit under set -e
local x=$(cmd) local can mask cmd failure status
x=$(cmd) Default: errexit off inside substitution
$1 with set -u Missing argument is an unbound variable
cat <(cmd) Failure inside <(...) may not fail the parent
Function called from if Errexit suppression extends into function body
set -e inside a function Option persists after the function returns

The fundamentals are simple; most strict-mode problems come from these exceptions.


Summary

Bash strict mode is a label for set -e, set -u, and pipefail used together. Errexit exits on non-zero statuses only for eligible commands, with well-documented exemptions in conditionals, short-circuit lists, and negated tests. Nounset catches unset names and missing positional parameters. pipefail stops the last pipeline stage from hiding an earlier failure, including SIGPIPE when an early consumer closes the pipe.

The surprises cluster around command substitution (inherit_errexit, local var=$(cmd)), arithmetic increment, functions called from if, and process substitution. Use the template when it fits, add inherit_errexit when $() must honor errexit, and reach for explicit if tests when failure is part of the design.


References


Frequently Asked Questions

No. It is a strong default for scripts where unexpected failure should stop execution, but it conflicts with commands that use non-zero status as normal control flow unless you wrap them in if, ||, or similar. Libraries sourced into interactive shells should not enable strict mode without warning.

Why did set -e not exit?

Bash suspends errexit for commands in if/while/until tests, for commands in &&/|| lists except the one after the final && or ||, and for negated commands with !. Arithmetic like ((count++)) when the expression evaluates to zero can also exit under errexit.

Why did grep stop my script?

grep returns 1 when there is no match. That is failure for errexit when grep runs as a standalone command. Use if grep -q …, if ! grep -q …, or grep … || true when no match is expected.

Does set -e work inside $()?

Not inside the substitution by default. Bash normally clears errexit in the subshell created for $(...), although the command substitution itself can still return a non-zero status to the parent. Bash 4.4+ provides shopt -s inherit_errexit, which makes the substitution inherit the caller's -e setting.

Should I add IFS=$'\n\t'?

Only when you have a specific word-splitting problem and understand the global side effect. Quoted variables and arrays solve most issues. Treat IFS changes as a deliberate technique, not part of the strict-mode bundle.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)