Skip to main content
Bash · lesson 14 of 22
Bash intermediate Lesson 14 of 22

Also part of: GitHub Actions

Bash Error Handling

Write robust scripts with set -euo pipefail, trap ERR, exit codes, meaningful error messages, and safe cleanup patterns.

By SkillByExample · Updated

Every example below is a complete script, run with bash 5.2 on Ubuntu 24.04.

Why Scripts Need set -e

By default Bash keeps going after a command fails:

cp missing.txt backup/
echo "backup complete"
$ bash no-e.sh
cp: cannot stat 'missing.txt': No such file or directory
backup complete

The script announced success after the one step that mattered had failed. set -e stops the script at the first failing command:

set -e
cp missing.txt backup/
echo "backup complete"
$ bash with-e.sh; echo "exit status: $?"
cp: cannot stat 'missing.txt': No such file or directory
exit status: 1

Most scripts start with set -euo pipefail. -u makes an unset variable an error, and pipefail makes a pipeline fail when any stage fails (see the input/output lesson). Some style guides also add IFS=$'\n\t' so that unquoted expansions no longer split on spaces.

Exit Codes

Every command reports an exit code. Some values have fixed meanings:

ls /nonexistent 2>/dev/null;       echo "ls missing file:   $?"
nosuchcommand 2>/dev/null;         echo "unknown command:   $?"
touch plain.sh; ./plain.sh 2>/dev/null; echo "not executable:    $?"
timeout 1 sleep 5;                 echo "timeout killed it: $?"
bash -c 'kill -TERM $$';           echo "killed by SIGTERM: $?"
$ bash codes.sh
ls missing file:   2
unknown command:   127
not executable:    126
timeout killed it: 124
Terminated
killed by SIGTERM: 143
CodeMeaning
0Success
1General error
2Misuse, bad arguments (GNU ls uses it for “cannot access”)
124timeout killed the command
126 / 127Not executable / command not found
128+NKilled by signal N (130 = Ctrl+C, 143 = SIGTERM)

Use 1 and 2 for your own scripts. A code above 125 looks like a shell or signal failure to whoever reads it.

When set -e Kills a Script Silently

set -e treats every non-zero status as an error, but some commands use non-zero to mean “no”.

set -euo pipefail
printf 'INFO ok\nINFO ok\n' > app.log
errors=$(grep -c ERROR app.log)
echo "errors: $errors"
$ bash grep-e.sh; echo "exit status: $?"
exit status: 1

The script died without printing anything. grep -c printed 0 and exited 1 because there were no matches, and set -e treated the assignment as a failure. A clean log is exactly the case that kills this script. Mark that status as acceptable, or test it in a condition:

set -euo pipefail
printf 'INFO ok\nINFO ok\n' > app.log
errors=$(grep -c ERROR app.log || true)
echo "errors: $errors"
if grep -q ERROR app.log; then echo "found errors"; else echo "log is clean"; fi
$ bash grep-fixed.sh
errors: 0
log is clean

diff, cmp, test and (( )) all return non-zero for a normal “no” answer, so the same applies to them.

Where set -e Is Switched Off

set -e doesn’t apply to commands in an if or while condition, or on the left of && or ||. That exemption also covers everything inside a function called from those places:

set -e
deploy() {
  false
  echo "  ...deploy kept going after false"
}
echo "called under || true:"
( deploy ) || true
echo "called from if:"
if deploy; then echo "  deploy 'succeeded'"; fi
$ bash exempt.sh
called under || true:
  ...deploy kept going after false
called from if:
  ...deploy kept going after false
  deploy 'succeeded'

Both calls went past false, and the if even reported success, because the function’s status is that of its last echo. The first call is exempt too: ( deploy ) runs on the left of ||, so set -e is off inside it as well. Functions that must stop on a failure need explicit checks (Practice task 2).

trap ERR: Report What Failed

An ERR trap runs when a command fails. It is the place to print which command it was and on which line.

set -eEuo pipefail
on_error() {
  echo "ERROR: '$BASH_COMMAND' exited $1 at line $2" >&2
}
trap 'on_error $? $LINENO' ERR

echo "Starting..."
cp /nonexistent /tmp/
echo "This never runs"
$ bash trap-err.sh
Starting...
cp: cannot stat '/nonexistent': No such file or directory
ERROR: 'cp /nonexistent /tmp/' exited 1 at line 8

$? still holds the failing command’s status when the handler starts, so reading it on the handler’s first line would work too (that’s what cleanup does below). $LINENO is different: read inside the function, it gives the handler’s own line. Passing both from the trap string keeps them together and correct. The capital -E makes functions and subshells inherit the trap. Without it, a failure inside a function isn’t reported.

trap EXIT: Guaranteed Cleanup

An EXIT trap runs however the script ends: success, failure or exit.

set -euo pipefail
workdir=""
cleanup() {
  local code=$?
  [[ -n $workdir ]] && rm -rf -- "$workdir"
  echo "cleanup ran (exit $code); workdir gone: $([[ -e $workdir ]] && echo no || echo yes)" >&2
}
trap cleanup EXIT

workdir=$(mktemp -d)
echo "working in a temp dir"
[[ ${1:-} == fail ]] && false
echo "work done"
$ bash trap-exit.sh; bash trap-exit.sh fail
working in a temp dir
work done
cleanup ran (exit 0); workdir gone: yes
working in a temp dir
cleanup ran (exit 1); workdir gone: yes

The temp directory was removed in both runs. Register the trap before you create the resource, and have cleanup save $? in its first line so it can see the script’s real exit status.

set -u: Catch Typos and Missing Config

set -u
name="${USERNAME:-anonymous}"
echo "name=$name"
if [[ -v MY_VAR ]]; then echo "MY_VAR set"; else echo "MY_VAR not set"; fi
echo "deploying to $TARGET_HOST"
echo "never printed"
$ bash unset.sh
name=anonymous
MY_VAR not set
unset.sh: line 5: TARGET_HOST: unbound variable

Without -u, the script would have printed “deploying to ” and carried on with an empty host name. Give optional variables a ${VAR:-default}, and test for them with [[ -v VAR ]].

Validating Arguments Up Front

Check every argument before doing any work. The die helper prints the message to stderr and exits with the right code:

set -euo pipefail
die()   { echo "ERROR: $1" >&2; exit "${2:-1}"; }
usage() { echo "Usage: $(basename "$0") [-v] [-o FILE] <input>" >&2; exit 2; }

verbose=false output=""
while [[ $# -gt 0 ]]; do
  case "$1" in
    -o|--output)  [[ $# -gt 1 ]] || die "option $1 requires an argument" 2
                  output="$2"; shift 2 ;;
    -v|--verbose) verbose=true; shift ;;
    -h|--help)    usage ;;
    --)           shift; break ;;
    -*)           die "unknown option: $1" 2 ;;
    *)            break ;;
  esac
done

[[ $# -eq 1 ]] || usage
[[ -f $1 ]] || die "input file not found: $1"
$verbose && echo "input=$1 output=${output:-stdout}"
echo "processing $1"
$ touch data.csv; bash args.sh -v -o out.txt data.csv; bash args.sh -x data.csv; echo "exit $?"; bash args.sh -o; echo "exit $?"; bash args.sh; echo "exit $?"
input=data.csv output=out.txt
processing data.csv
ERROR: unknown option: -x
exit 2
ERROR: option -o requires an argument
exit 2
Usage: args.sh [-v] [-o FILE] <input>
exit 2

Each misuse got its own message and exit code 2. The [[ $# -gt 1 ]] check caught -o with nothing after it. Without it, $2 would trip set -u with a confusing “unbound variable” message. -- ends option parsing, so an input file can have a name that starts with -.

Retries with Backoff

Temporary network failures are normal. Retry a limited number of times, and double the wait after each attempt:

set -euo pipefail
retry() {
  local max="$1" delay="$2" attempt=1
  shift 2
  until "$@"; do
    (( attempt >= max )) && { echo "failed after $max attempts: $*" >&2; return 1; }
    echo "attempt $attempt failed; retrying in ${delay}s" >&2
    sleep "$delay"
    (( attempt++, delay *= 2 ))
  done
}
flaky() {   # succeeds on the 3rd call
  local n=$(( $(cat .calls 2>/dev/null || echo 0) + 1 ))
  echo "$n" > .calls
  (( n >= 3 ))
}
retry 5 1 flaky && echo "flaky succeeded"
retry 2 1 false || echo "gave up on false"
$ bash retry.sh
attempt 1 failed; retrying in 1s
attempt 2 failed; retrying in 2s
flaky succeeded
attempt 1 failed; retrying in 1s
failed after 2 attempts: false
gave up on false

"$@" runs the command exactly as it was passed in, so retry 5 2 curl -sf "$url" -o out.json works unchanged. Put a timeout on each attempt as well (retry 5 2 timeout 10 curl ...), so a hung call can’t use up the whole retry budget.

Practice

  1. Write die so its message includes the line number it was called from.

    Solution

    Inside a function, BASH_LINENO[0] is the line that called it:

    die() { echo "ERROR (line ${BASH_LINENO[0]}): $1" >&2; exit "${2:-1}"; }
    
    config="app.yml"
    echo "checking config"
    [[ -f $config ]] || die "config not found: $config"
    echo "never printed"
    $ bash p1.sh
    checking config
    ERROR (line 5): config not found: app.yml
  2. Change deploy from exempt.sh so it stops at the failing step and reports failure, even when called from an if.

    Solution
    set -e
    deploy() {
      false || return
      echo "  ...deploy kept going after false"
    }
    if deploy; then echo "deploy succeeded"; else echo "deploy failed with status $?"; fi
    $ bash p2.sh
    deploy failed with status 1

    A bare return passes on the status of the last command, which is false’s 1.

  3. Make a job refuse to start while another copy is already running. Use a lock file that is always removed when the job exits.

    Solution

    set -C (noclobber) makes > fail when the file already exists, so creating the lock is an atomic test-and-set:

    set -euo pipefail
    lock=./job.lock
    if ! ( set -C; echo $$ > "$lock" ) 2>/dev/null; then
      echo "another run holds the lock (pid $(cat "$lock"))" >&2
      exit 1
    fi
    trap 'rm -f "$lock"' EXIT
    echo "got the lock; working"
    sleep 2
    echo "finished"
    $ bash p3.sh & sleep 0.5; bash p3.sh; echo "second run exit: $?"; wait; ls job.lock 2>&1
    got the lock; working
    another run holds the lock (pid 10)
    second run exit: 1
    finished
    ls: cannot access 'job.lock': No such file or directory

    The second run was refused, and after the first one finished, its EXIT trap removed the lock.

Frequently Asked Questions

What does set -euo pipefail do?
set -e exits on any command failure. set -u treats unset variables as errors. set -o pipefail makes a pipeline fail if any stage fails (not just the last command). Together they catch most common silent failure modes.
Why does set -e cause unexpected exits in my script?
set -e exits on any non-zero exit code. Commands used in conditions (if, while, || checks) are exempt. But commands like grep returning 1 for no match, or cd failing, will exit the script. Be explicit: use || true to allow a command to fail, or check with if.
What should the exit code of my script be?
0 for success, 1 for general errors, and 2 for misuse (wrong arguments). The shell reserves 126 (found but not executable), 127 (command not found) and 128+N (killed by signal N), so keep your own codes between 1 and 125 so they cannot be mistaken for those.
Why does set -e not stop my function when I call it inside an if?
set -e is switched off for everything run as part of an if/while condition or on the left of && or ||, including every command inside a function called there. A failing command in that function no longer stops anything, and execution continues. Add explicit checks such as cmd || return inside functions that must stop on failure.