---
title: 'Error Handling'
source: 'https://academia.sh/en/courses/shell-programming/error-handling'
course: 'Shell Programming'
language: en
updated: '2026-08-17T18:10:00+00:00'
license: 'CC BY-SA 4.0'
---

# Error Handling

Strict mode options, the scope and exceptions of early exit, undefined variable checking, pipeline exit codes, and the error-catching hook.

The script produces correct output, but only when everything goes right. When a
command fails, the report is left half-finished, and the script's exit code does
not report it. An undefined variable silently expands to an empty string. An
error in the middle of a pipeline stays invisible.

The shell's default behavior is tolerant of failure: even if a command fails, the
next one still runs. This is the right choice for interactive use — a mistyped
command should not close the session. In a script, it works the other way.

**Strict mode** is a set of options that narrows the shell's tolerance. This
lesson takes up the options one by one, showing what each catches and — more
importantly — what it does not catch.

## Early Exit

The `set -e` option terminates the shell after a command whose exit code is
nonzero.

```sh
#!/usr/bin/env bash
set -e
echo "1: start"
false
echo "2: THIS LINE IS NOT PRINTED"
```

```
1: start
```

```sh
echo $?
```

```
1
```

The script stopped before reaching the second line and carried the failure code
outward. The gain here is not just convenience: a script producing half its
output and reporting success misleads every system that calls it.

## Early Exit's Exceptions

`set -e` does not treat a command's failure as fatal everywhere. The exceptions
make sense, but there are many of them, and not knowing them gives a false sense
of security.

```sh
#!/usr/bin/env bash
set -e
if false; then echo "a"; fi
echo "1: failure in a condition does not stop the script"
false || echo "2: failure on the left of || does not stop it"
false && echo "not reached"
echo "3: failure on the left of && does not stop it either"
false | true
echo "4: does not stop if the pipeline's last command succeeds"
! false
echo "5: a negated command does not stop it"
f() { false; echo "f continued"; }
if f; then :; fi
echo "6: set -e is suspended inside a function called in a condition"
```

```
1: failure in a condition does not stop the script
2: failure on the left of || does not stop it
3: failure on the left of && does not stop it either
4: does not stop if the pipeline's last command succeeds
5: a negated command does not stop it
f continued
6: set -e is suspended inside a function called in a condition
```

The first five of the six exceptions are by design: the failure of a command
used as a condition carries information, it is not an error. The sixth is the
most dangerous — early exit is suspended for the **entire body** of a function
called within a condition, and real errors inside that body become invisible.

For this reason, `set -e` alone is not a guarantee. Critical commands' exit
codes must be checked separately, or an error hook must be set up.

## Undefined Variable Checking

The `set -u` option treats the expansion of an undefined variable as an error.

```sh
#!/usr/bin/env bash
set -u
echo "log: ${LOG}"
```

```
./u.sh: line 3: LOG: unbound variable
```

This option catches the most destructive class of error in shell scripts. The
line `rm -rf "$DIR/"` becomes `rm -rf /` when `DIR` is undefined — with the
option active, the script never reaches that line at all.

The option makes using default-value notation mandatory:

```sh
#!/usr/bin/env bash
set -u
echo "log: ${LOG:-access.log}"
echo "first argument: ${1-none}"
echo "argument count: $#"
```

```
log: access.log
first argument: none
argument count: 0
```

The `${v-fallback}` and `${v:-fallback}` notations introduced in the Variables
and Quoting Rules lesson turn into a requirement here: if an unset positional
parameter is going to be read, a default value must be written. This also
produces a side effect that documents the script's interface.

## Pipeline Exit Code

`set -o pipefail` sets a pipeline's exit code equal to the code of the
**rightmost** command that returns a nonzero code. It was introduced in the
Pipelines lesson; it is an inseparable part of strict mode.

```sh
#!/usr/bin/env bash
set -eo pipefail
grep '999' access.log | wc -l
echo "THIS LINE IS NOT PRINTED"
```

```
       0
```

```sh
echo $?
```

```
1
```

Without `pipefail`, this script would print `0` and finish successfully: `wc`
ran without a problem, and the pipeline's code was its code.

With `pipefail` and `set -e` together, it must be kept in mind that pipelines
cut off by `SIGPIPE` will also count as failures. Pipelines in the form
`command | head -5` must be suppressed deliberately.

## Narrowing the Field Separator

The assignment `IFS=$'\n\t'` removes the space from word splitting: splitting
happens only on newline and tab.

```sh
IFS=$'\n\t'
d="a b:c"
printf '<%s>\n' $d
```

```
<a b:c>
```

Even the unquoted expansion did not split. This is an extra defense against the
class of errors caused by file names containing spaces.

A warning is needed: this setting is global and affects every piece of code
that relies on splitting by space. A loop that wants to split a variable into
its words runs for a single round after this setting. While strict mode's other
three options are recommended unconditionally, this one has to be decided by
looking at what the script does.

## The Arithmetic Trap

The Arithmetic and Command Substitution lesson showed that the `(( ))`
command's exit code is the **inverse** of the value. Under strict mode this
turns into an error that stops the script:

```sh
#!/usr/bin/env bash
set -e
counter=0
(( counter++ ))
echo "THIS LINE IS NOT PRINTED (counter=$counter)"
```

```
```

```sh
echo $?
```

```
1
```

The postfix form `counter++` returned the **old** value — that is, zero — and
`(( ))` counted this as a failure. The safe form is arithmetic expansion:

```sh
#!/usr/bin/env bash
set -e
counter=0
counter=$(( counter + 1 ))
echo "counter=$counter"
```

```
counter=1
```

This trap shows why strict mode is applied carefully: the options do not
change the script's meaning, but they change which situations count as an
"error," and some idioms are incompatible with this new definition.

## Suppressing Expected Failures

It is normal for some commands to return a nonzero code; a filter tool's "no
match" report is the typical example. Under strict mode, these situations are
suppressed explicitly.

```sh
#!/usr/bin/env bash
set -euo pipefail
count=$(grep -c 'NOMATCH' access.log || true)
echo "match count: $count"
if grep -q 'NOMATCH' access.log; then echo "found"; else echo "not found"; fi
```

```
match count: 0
not found
```

Two methods were used. `|| true` zeroes the code unconditionally; it suits
cases where the number itself is meaningful. Calling within an `if`, on the
other hand, already interprets the failure as a condition; this is the correct
form for an existence question.

`|| true` must be used with restraint. Added to every line, strict mode loses
its meaning, and the script reverts to its old form that swallows errors. The
rule: **suppression is done knowing why that command's failure is expected,
and documented with a comment when necessary.**

## The Error Hook

`trap ... ERR` runs a handler for every command that triggers early exit.
Reporting where the error occurred makes the silent exits strict mode produces
traceable.

The `set -E` option makes this hook valid inside functions and subshells too;
without it, the hook only runs at the top level.

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

handle_error() {
  local code=$? line=$1
  printf 'report.sh: error at line %d, exit code %d\n' "$line" "$code" >&2
  exit "$code"
}
trap 'handle_error "$LINENO"' ERR

echo "1: start"
grep -q 'NOMATCH' access.log
echo "2: THIS LINE IS NOT PRINTED"
```

```
1: start
report.sh: error at line 13, exit code 1
```

The first line in the hook's body is written as `local code=$?`; `$?` must be
read before any other command runs, or its value changes. `$LINENO` is left
inside single quotes so that it expands where the hook is **triggered**, not
where it is **set up**.

The `trap` command itself and signal handling are the next lesson's subject.

## The Strict Mode Header

This lesson's result is a fixed header placed at the top of scripts:

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

Four options close four separate error classes: a failed command, an undefined
variable, a code lost in a pipeline, and an error hook lost inside a function.
The fifth — narrowing `IFS` — is decided on a per-script basis.

The header is not a guarantee, it is a baseline. Keeping `set -e`'s exceptions
in mind, critical commands' codes still have to be checked by hand. Strict
mode catches what gets overlooked; it does not stand in for what should not be
overlooked.

## Summary

- `set -e` terminates the shell after a command that returns a nonzero code;
  it is suspended in condition contexts, on the left of chain operators, and
  inside the body of functions called in a condition.
- `set -u` treats undefined variable expansion as an error and makes
  default-value notation mandatory.
- `set -o pipefail` ties a pipeline's exit code to the code of its first
  failing link.
- `(( counter++ ))` reports failure at a value of zero; under strict mode,
  `counter=$(( counter + 1 ))` is written instead.
- Expected failures are suppressed deliberately, with `|| true` or a condition
  context.
- `trap ... ERR`, together with `set -E`, sets up a hook that reports the
  error's line number.

## Next Step

Strict mode stops the script the moment there is an error — but what does it
leave behind at the point where it stops? A half-written output file,
uncleaned temporary files, a lock another process is waiting on. The next
lesson takes up signals and cleanup hooks that run on exit; the safe creation
of a temporary file and its deletion on every exit path will also be set up
there.
