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.