Debugging Bash Scripts: set -x, set -e, traps

Bash scripts fail silently and confusingly by default. A handful of options change that, turning your scripts into ones that crash loudly and show you exactly where. Five techniques cover almost every “why didn’t this work?” moment.

set -e: exit on any error

By default, bash continues running even if a command fails. set -e makes it stop:

#!/bin/bash
set -e

cd /nonexistent          # this fails
echo "still here"        # without set -e, this would print

Caveats: set -e doesn’t stop on commands inside if conditions, ||, or && chains. Sometimes that’s good; sometimes it surprises you.

set -u: error on undefined variable

#!/bin/bash
set -u

echo "Hello, $name"      # error: name: unbound variable

Catches typos in variable names that would otherwise silently produce empty strings.

set -o pipefail: catch errors in pipelines

Without it, a pipeline’s exit status is just the LAST command’s:

false | true             # exit status: 0 (true succeeded)

With it, the pipeline fails if ANY command fails:

set -o pipefail
false | true             # exit status: 1

The standard preamble

Put this at the top of every script:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'nt'              # safer field splitting

This makes your script behave like a normal program — it dies on error, complains about typos, and respects pipeline failures.

set -x: trace every command

Print every command before it runs (with variable expansion shown):

#!/bin/bash
set -x

name="alice"
echo "Hello, $name"
ls /tmp

# Output:
# + name=alice
# + echo 'Hello, alice'
# + ls /tmp

Turn on/off in the middle of a script:

set -x          # turn on tracing
problematic_function
set +x          # turn off

Run an existing script with tracing without editing:

bash -x ./myscript.sh

trap: cleanup on exit or signal

Run code when the script exits — successfully or not:

#!/bin/bash
set -euo pipefail

tmpdir=$(mktemp -d)

cleanup() {
    rm -rf "$tmpdir"
    echo "cleaned up"
}
trap cleanup EXIT

# work in tmpdir
echo "data" > "$tmpdir/file"
# script exits — cleanup runs automatically

Catch specific signals:

trap 'echo "got SIGTERM"; exit 1' TERM
trap 'echo "got SIGINT"; exit 1' INT
trap 'echo "got error on line $LINENO"' ERR

The death-trap pattern (show line on error)

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

trap 'echo "ERROR on line $LINENO" >&2' ERR

# now any command failure prints the line number first

Print debug info conditionally

VERBOSE=${VERBOSE:-0}

debug() {
    [[ $VERBOSE -eq 1 ]] && echo "[DEBUG] $*" >&2
}

debug "starting backup"
# Run with: VERBOSE=1 ./script.sh

shellcheck: lint your scripts

The single best tool for finding bash bugs. Catches quoting issues, unused variables, broken patterns, common mistakes.

sudo apt install shellcheck      # debian/ubuntu
sudo dnf install ShellCheck      # fedora
brew install shellcheck          # mac

shellcheck myscript.sh

Run shellcheck on every script you write. It will save you hours.

The full debug template

#!/usr/bin/env bash
set -euo pipefail
IFS=$'nt'

# Trace everything if DEBUG=1
[[ "${DEBUG:-0}" == "1" ]] && set -x

# Cleanup
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
trap 'echo "ERROR on line $LINENO" >&2' ERR

# Helpers
log() { echo "[$(date +%H:%M:%S)] $*" >&2; }
die() { echo "ERROR: $*" >&2; exit 1; }

# Sanity checks
[[ $# -ge 1 ]] || die "usage: $0 SOURCE_DIR"
[[ -d "$1" ]]  || die "not a directory: $1"

# Real work
log "processing $1"
# ...

Real debugging session

# 1. Add tracing
bash -x myscript.sh

# 2. If the issue is silent, add verification
echo "before risky step: file size = $(stat -c%s "$file")"
risky_step
echo "after: $?"

# 3. Use shellcheck for static analysis
shellcheck myscript.sh

# 4. Bisect with set -e on / off
# Comment out half the script, find which half fails

# 5. Run interactively (no -e) to inspect state after failure
bash -i  # or 'bash --rcfile <(echo "set -x")'

Common mistakes

  • Forgetting set -e and not noticing scripts continue past failures.
  • Using set -e with commands that legitimately may fail — guard them with || true.
  • Not running shellcheck. It catches real bugs every time.
  • trap’ing EXIT but not cleaning up on partial state — make cleanup idempotent.

What to learn next

Bash scripting is covered. Next big section: storage — disks, partitions, mounting, LVM. The plumbing that holds your data.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *