Linux/Bash

Writing Safe Bash Scripts With set -euo pipefail and Its Trade-offs

6 min read by DebuggedIt

Quick answer

Many shell scripts begin with the same line: set -euo pipefail. It is widely recommended as "strict mode", and it does catch real bugs. It is also frequently...

Many shell scripts begin with the same line: set -euo pipefail. It is widely recommended as "strict mode", and it does catch real bugs. It is also frequently misunderstood, and treating it as a safety net can create a false sense of security. To write reliable scripts, you need to know exactly what each flag does, where it silently does nothing, and which habits matter more than the flags.

What Each Option Does

The -e option tells Bash to exit when a command returns a nonzero status. The -u option treats references to unset variables as errors instead of expanding to an empty string, which prevents disasters like deleting the wrong directory because a variable was never set. The pipefail option changes pipelines so the exit status is that of the last command that failed, rather than always the status of the final command.

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

backup_dir="${BACKUP_DIR:?BACKUP_DIR must be set}"
rm -rf "${backup_dir}/old"

The parameter expansion with a question mark is a useful companion to -u. It exits with a clear message when a required variable is missing, instead of a vague unbound variable error. Use the default-value forms, such as a variable with a colon and a dash, when an empty value is acceptable.

Where set -e Does Not Do What You Expect

The behavior of -e has many exceptions, which is why experienced shell programmers treat it cautiously. Errors are ignored in several contexts, and the rules are subtle enough that they differ in details between Bash versions and POSIX mode, so consult the Bash manual for your version.

  • Conditions: a failing command inside an if, while, or until condition does not exit the script, and neither does a command that is part of a list joined with the AND or OR operators, except for the last command.
  • Functions called in conditions: if you call a function as part of an if test, -e is effectively disabled inside that function, so failures in the middle of it are ignored.
  • Command substitution with local: writing local value=$(some_command) hides the failure of some_command because the exit status comes from local. Declare the variable first, then assign it on a separate line.
  • Arithmetic that evaluates to zero: the expression ((count++)) returns a nonzero status when count is zero, which will abort a script under -e even though nothing failed.
  • Commands that use nonzero for normal results: grep returns 1 when there is no match, which is not an error in many scripts but will exit under -e unless you handle it.
# Hides failure of the command
local out=$(risky_command)

# Preserves it
local out
out=$(risky_command)

# Handle an expected nonzero status explicitly
if ! grep -q "ready" status.txt; then
    echo "not ready yet"
fi

Why pipefail and -u Still Have Edge Cases

Pipefail is generally helpful, but it can produce surprising failures. Commands that close their input early, such as head, can cause the upstream command to receive a broken pipe signal and return a nonzero status, which then fails the whole pipeline. This is normal pipe behavior revealed by pipefail, not a bug, and you may need to handle it deliberately.

The -u option has a known quirk in older Bash versions: expanding an empty array with the all-elements syntax was treated as an unbound variable before Bash 4.4. If your scripts run on systems with older Bash, test them there. Also note that macOS ships an old Bash by default, which matters if scripts are shared across machines.

Habits That Matter More Than Flags

Strict mode catches some mistakes, but the most damaging shell bugs come from quoting and from assuming commands succeeded. Build these habits into every script:

  • Quote variable expansions, such as "$file", so spaces and glob characters do not split or expand them.
  • Use a trap on EXIT to clean up temporary files, and on ERR if you want to report where a failure happened.
  • Create temporary files with mktemp instead of predictable names.
  • Check required commands and arguments at the start and fail with a clear message.
  • Run a static analysis tool such as ShellCheck on your scripts; it detects many quoting and logic problems that no runtime flag can catch.
tmp="$(mktemp -d)"
cleanup() { rm -rf "$tmp"; }
trap cleanup EXIT

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

When to Move Beyond Bash

Strict mode makes Bash safer, but it does not make it a good language for complex logic. If a script needs data structures, careful error handling, or parsing of structured formats like JSON, a language such as Python or Go usually produces more reliable and testable code. Keep Bash for gluing commands together and for short, well-defined automation tasks.

Frequently Asked Questions

Should every script use set -euo pipefail?

For most automation scripts it is a sensible default, as long as you understand its exceptions. In scripts that intentionally tolerate failures, disable parts of it locally or handle statuses explicitly.

Why did my script exit with no error message?

Under -e, a command returned nonzero and the shell exited silently. Add a trap on ERR that prints the failing line, or use set -x temporarily to trace execution.

How do I allow a command to fail on purpose?

Append an OR operator with true, or place the command inside an if. Prefer explicit handling so the intent is clear to readers.

Is set -e portable?

The option exists in POSIX shells, but details and the pipefail option differ across shells and versions. If you target sh rather than Bash, test on that shell and consult its documentation.

Summary

The combination of -e, -u, and pipefail catches many common failures, but it has exceptions that can hide errors, especially in conditions, functions, and command substitutions. Combine it with quoting, traps, temporary file hygiene, and static analysis, and use a more capable language when logic grows. Always verify behavior against the Bash manual for the version you deploy on.