Bash Error Handling: set -euo pipefail and Why Your Script Needs It

Bash Error Handling: set -euo pipefail and Why Your Script Needs It

A bash script without error handling has a specific failure mode: it keeps going. A command fails, prints something to stderr that nobody reads, and the next line runs anyway with the assumption that the previous one worked.

cd /some/directory       # this fails, directory does not exist
rm -rf ./*               # this runs anyway, in whatever directory you were in

That is not hypothetical. It is one of the most common ways scripts destroy things.

The three options

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

Three separate behaviours, worth understanding individually rather than as a magic incantation.

set -e, exit on error

The shell exits when a command returns non-zero.

set -e
cd /nonexistent          # script stops here
rm -rf ./*               # never runs

This is the one that prevents the disaster above.

set -u, error on unset variables

Referencing a variable that was never set becomes an error rather than an empty string.

set -u
echo "$TYPO_IN_NAME"     # error, script stops

The value of this is best shown by the failure it prevents:

# Without -u, if DEST is unset:
rm -rf "$DEST"/          # becomes rm -rf /

That has happened to real people at real companies.

set -o pipefail

A pipeline normally reports the exit status of its final command only.

false | true; echo $?    # prints 0

The first command failed and the pipeline reported success. With pipefail:

set -o pipefail
false | true; echo $?    # prints 1

This matters constantly, because piping into grep, sort, tee, or jq is normal and a failure upstream would otherwise be silent.

curl -s "$URL" | jq '.items[]' > out.json

Without pipefail, curl failing produces an empty file and a success status.

Where set -e does not help

Treating set -e as complete protection is the mistake that follows learning about it. It deliberately does not trigger in several places:

set -e

# Conditions: the whole point is to test the result
if failing_command; then echo yes; fi

# Left side of && or ||
failing_command || echo "handled"

# Any command that is not the last in a && chain
failing_command && echo "never printed"

# Inside a function whose result is being tested
myfunc() { failing_command; echo "still runs"; }
if myfunc; then :; fi

These exemptions are necessary, since without them you could not test anything. They also mean explicit checks are still required in those positions.

The other well-known trap:

set -e
count=$(grep -c pattern file)    # exits if grep finds nothing, since grep returns 1

grep returning 1 for no matches is correct behaviour, not an error. Handle it:

count=$(grep -c pattern file || true)

Allowing a failure deliberately

# Expected to sometimes fail
rm -f /tmp/maybe-there || true

# Capture the status instead
if ! systemctl is-active --quiet myservice; then
    echo "not running, starting it"
    systemctl start myservice
fi

# Keep the status for inspection
set +e
some_command
status=$?
set -e

The || true form is preferable to disabling the option, because it marks that one specific failure as expected rather than turning off safety for a region.

Cleanup with trap

Error handling that leaves temporary files behind is only half done.

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

tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT

# work in $tmpdir; it is removed whether the script succeeds,
# fails, or is interrupted

EXIT fires on any exit path, which is what makes this reliable. For a message on failure specifically:

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

A template worth reusing

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

cleanup() {
    local status=$?
    rm -rf "${tmpdir:-}"
    exit "$status"
}
trap cleanup EXIT

die() { echo "error: $*" >&2; exit 1; }

tmpdir=$(mktemp -d)

command -v jq >/dev/null || die "jq is required"
[[ -f "$1" ]] || die "usage: $0 <input-file>"

# real work here

The IFS line restricts word splitting to newlines and tabs rather than spaces, which prevents filenames with spaces from splitting into multiple arguments.

${tmpdir:-} provides an empty default so the cleanup function does not itself trip set -u if the script fails before tmpdir is assigned. That detail is easy to miss and produces a confusing error inside your error handler.

Quoting, which matters more than any of this

No amount of set -e saves an unquoted variable.

rm -rf $DIR/*      # splits on spaces, globs, and generally misbehaves
rm -rf "$DIR"/*    # correct

Quote every expansion unless you have a specific reason not to. Run shellcheck over your scripts; it catches this and most of the other common mistakes automatically, and it is the single highest-value tool for shell scripting.

Our bash scripting guide covers the language itself, and writing your first bash script is the starting point.

Frequently Asked Questions

What does set -e actually do?

It makes the shell exit when a command returns a non-zero status. Without it, a failed command prints an error and the script carries on to the next line, which is how a backup script can delete a destination and then fail to copy anything into it.

Why is set -u useful?

It turns a reference to an unset variable into an error instead of an empty string. That single change prevents the classic catastrophe of rm -rf "$DIR"/ expanding to rm -rf / when DIR was never assigned.

What problem does pipefail solve?

A pipeline normally reports only the exit status of its last command, so a failure earlier in the chain is invisible. With pipefail the pipeline fails if any component fails, which matters whenever you pipe something into grep, sort, or tee.

Does set -e catch every error?

No, and treating it as a guarantee is a mistake. It does not trigger for commands in conditions, commands followed by two ampersands or two pipes, or anything in a function whose result is being tested. Explicit checks are still needed in those places.

How do I let a specific command fail without exiting?

Append or-true to it, as in somecommand || true, or capture the status with an if statement. Both tell the shell that this particular failure is expected, which is clearer to a reader than disabling the option entirely.

Should I use these options in every script?

In anything that changes state, yes. For a throwaway one-liner it is unnecessary. Any script that deletes, deploys, or writes to a database should have them, along with a trap for cleanup.