One of the most repeated headers in modern Bash scripts is:

set -euo pipefail

It is often described as Bash “strict mode.” The nickname is useful, but technically there is no single strict mode: that line simply enables three separate behaviors that, together, make many errors stop passing silently.

The rough idea is:

-e          abort on certain failed commands
-u          fail when using undefined variables
-o pipefail expose failures from any stage of a pipeline

That is extremely useful in CI, deployment, and automation scripts. But set -e is more subtle than it first appears, and Bash exposes many other options worth knowing.

First: what set actually does

set is a shell builtin. Among other jobs, it can enable and disable behavior flags in Bash itself.

For example:

set -e

enables errexit, while:

set +e

disables it.

That convention can feel backwards compared with other Unix tools: with set, - normally enables an option and + disables it.

Long names also exist:

set -o errexit
set -o nounset
set -o pipefail
set -o xtrace

And you can inspect the current state with:

set -o

-e: errexit

The intent of:

set -e

is to terminate the script when a command returns a non-zero status.

Example:

#!/usr/bin/env bash
set -e

cp missing-file.txt /tmp/
echo "we should not get here"

cp fails, so Bash normally exits before reaching the echo.

This helps prevent a dangerous sequence:

critical command fails
        ↓
the script keeps going
        ↓
later steps operate on incomplete state
        ↓
the original error gets buried

But -e does not literally mean “any non-zero code always aborts.”

The big -e trap: shells need failures as part of normal control flow

Shell commands use exit statuses to make decisions.

For example:

if grep -q foo file.txt; then
  echo "found"
fi

grep -q returns 1 when it does not find the pattern. That should not kill the script: it is exactly the condition the if is testing.

For that reason Bash suppresses errexit in several contexts where failure can be part of control flow, including many uses inside:

if
while
until
&&
||
!
pipelines, subject to the pipeline's final status rules

For example:

set -e

false || echo "false was expected"
echo "the script continues"

This is deliberate.

The practical conclusion is important: set -e is a safety net, not a formal error-handling system.

For critical operations, it is still wise to state which failures are expected and which are fatal:

if ! deploy; then
  echo "deploy failed" >&2
  exit 1
fi

-u: nounset

With:

set -u

referencing an undefined variable becomes an error.

Without nounset:

echo "$API_TOKEN"

may silently expand to an empty string if API_TOKEN was never defined.

In deployment scripts that can be dangerous.

With set -u, Bash catches the mistake immediately.

When a variable is intentionally optional, make that explicit:

name="${NAME:-default}"

Or require it with a useful message:

: "${API_TOKEN:?API_TOKEN is required}"

That second form errors if the variable is missing or empty.

pipefail: the failure hidden inside a pipeline

Consider:

generate_data | transform | save

By default, a pipeline’s status is generally the status of the last command.

So something conceptually as simple as:

false | true

can finish with status 0, because true was the final stage.

With:

set -o pipefail

the pipeline reports failure if a relevant stage fails.

That is why:

set -euo pipefail

is much more useful than set -e alone for scripts that process data through pipes.

A common example is:

curl -fsS "$URL" | jq '.items' > items.json

Without pipefail, an early failure can be masked by another process that exits successfully.

So what does set -euo pipefail really mean?

A more accurate way to remember it is:

-e
surface many command failures instead of blindly continuing

-u
do not turn missing variables into empty strings without warning

pipefail
do not hide a failure in an intermediate pipeline stage

It is an excellent baseline for many non-interactive scripts.

-E: inherit the ERR trap

A very useful extension is:

set -E

also known as errtrace.

If you define:

trap 'echo "failed at line $LINENO" >&2' ERR

-E causes the ERR trap to be inherited in more contexts, such as functions and some subshells.

That is why you often see:

set -Eeuo pipefail

Example:

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

trap 'echo "error at line $LINENO" >&2' ERR

build() {
  false
}

build

For CI and deployment scripts, -E can significantly improve diagnostics.

Watch out for subshells and command substitution

There is another subtle point here.

A substitution such as:

value="$(command)"

runs in a subshell environment. Historically, Bash has not always inherited errexit inside that context in the way users expect.

Bash provides:

shopt -s inherit_errexit

so command substitutions inherit -e more consistently. POSIX mode also affects this behavior.

The broader lesson is simple: if your script’s correctness depends on a very specific errexit edge case, test that edge case explicitly.

-x: xtrace, Bash’s simplest debugger

To see commands as Bash executes them:

set -x

Example:

set -x
name="demo"
echo "$name"
set +x

Bash prints expanded commands before execution.

This is extremely useful in GitHub Actions, installation scripts, and remote troubleshooting.

But there is an important risk: it can print secrets.

So tracing is often disabled around credentials:

set +x
TOKEN="$(get_token)"
set -x

Better yet, design scripts so secrets do not appear in visible command arguments at all.

-v: print input as Bash reads it

set -v and set -x sound similar but show different things.

set -v

prints input lines as Bash reads them.

set -x

prints commands after relevant expansions, just before execution.

For practical debugging, -x is usually more useful.

-n: parse without executing

With:

set -n

Bash reads commands but does not execute them.

For checking an entire file, it is usually clearer to run:

bash -n deploy.sh

That is a cheap CI check for shell scripts.

It does not replace ShellCheck or tests, but it catches syntax errors before production is touched.

-C: noclobber, prevent accidental overwrites

With:

set -C

a redirection such as:

echo data > file.txt

fails if the target file already exists.

That protects against accidental clobbering.

If you deliberately want to overwrite it, Bash provides:

echo data >| file.txt

This can be useful in artifact-generation scripts where silently replacing an existing file would be a bug.

-a: automatically export variables

With:

set -a

variables that are created or modified are automatically exported to child processes.

A common pattern is:

set -a
source .env
set +a

python app.py

That makes variables loaded from .env available to children without writing export on every line.

Use it carefully: it can also export variables you never intended to propagate.

-f: disable globbing

Normally Bash expands patterns such as:

*.log

before invoking the command.

With:

set -f

pathname expansion is disabled.

This can be useful when handling input containing *, ?, or [] that should not be interpreted as filenames.

Re-enable globbing with:

set +f

Other set options

Bash has quite a few more. Some matter more in interactive shells than in scripts:

-b  notify      report background job completion immediately
-m  monitor     enable job control
-h  hashall     remember command locations
-B  braceexpand enable {a,b} expansion
-H  histexpand  enable ! history expansion
-P  physical    avoid logical symlink following in some directory operations
-k  keyword     place more assignment arguments into command environments
-p  privileged  alter behavior when effective and real UID/GID differ
-t  onecmd      execute one command and exit

There are also interactive editing modes exposed through set -o:

set -o vi
set -o emacs

set -o is your source of truth

Instead of memorizing everything, ask Bash:

set -o

You will see output similar to:

errexit         off
nounset         off
pipefail        off
xtrace          off
noclobber       off
...

And:

set +o

prints the state in a form that can be reused as set commands, which is handy when you need to save and restore shell configuration.

A practical header for CI and deployment

For many automation scripts, a reasonable baseline is:

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

During troubleshooting, temporarily add:

set -x

only around the section you need to inspect.

A trap can also provide useful context:

trap 'printf "ERROR: %s:%s: %s\n" "$0" "$LINENO" "$BASH_COMMAND" >&2' ERR

That does not turn Bash into a language with structured exceptions, but it gives CI logs a much better trail when something breaks.

Do not turn “strict mode” into dogma

There are scripts where set -e is awkward because many commands use non-zero statuses as a normal part of their API.

There is also legacy code that relies on optional variables and immediately breaks under set -u.

The right approach is not to paste set -euo pipefail mechanically into every file. It is to understand which guarantees each flag adds and to make expected failures explicit.

For example:

if grep -q pattern file; then
  echo "yes"
else
  echo "no"
fi

is better than expecting errexit to guess your intent.

The idea worth remembering

set -euo pipefail does not make Bash magically safe.

It changes several permissive defaults that can otherwise hide mistakes:

silent command failure       → visible failure
undefined variable           → error
partially broken pipeline    → error

And the other flags expand the toolbox:

-E  better ERR trap inheritance
-x  execution tracing
-v  input tracing
-n  parse without execution
-C  prevent accidental overwrite
-a  automatic export
-f  disable globbing

The important step is moving beyond copying set -euo pipefail as a ritual and understanding which shell semantics you are changing and why.