How to Debug a Bash Script (set -x, trap, and Common Mistakes)

Bash fails silently, swallows errors, and gives you no line numbers by default. Three built-in flags change all of that. Here is how to use them and what to look for.

The three modes every bash debugger uses

Bash ships three flags that together cover almost every debugging scenario. You can set them at the top of any script or pass them on the command line.

Flag Long form What it does
set -x set -o xtrace Print every command before it runs, with arguments expanded
set -e set -o errexit Exit immediately when any command returns a non-zero status
set -u set -o nounset Treat unset variables as an error and exit

set -x (xtrace) is the most immediately useful. Without it, a broken script exits with a cryptic message and no indication of what ran. With it, every expanded command prints to stderr before execution:

# Without set -x — script silently misbehaves ./deploy.sh # Error: deployment failed # With set -x — you see exactly what ran bash -x ./deploy.sh + source /etc/profile.d/env.sh + export APP_ENV=production + rsync -av ./dist/ user@host:/var/www/ + '[' -z '' ']' + echo 'Error: deployment failed'

set -e (errexit) turns bash from a language that ignores errors into one that stops on them. By default, bash runs every line regardless of exit codes. set -e makes it behave like most other languages:

#!/usr/bin/env bash set -e cp /source/file /destination/ # if this fails, script stops here rm /source/file # this line never runs if cp failed echo "Done" # neither does this

set -u (nounset) catches the silent bug of referencing a variable you forgot to set. Without it, $MYVAR expands to an empty string. With it, the script exits with an error:

#!/usr/bin/env bash set -u echo "Deploying to: $DEPLOY_HOST" # Without set -u: prints "Deploying to: " — silent empty string # With set -u: bash: DEPLOY_HOST: unbound variable — exits immediately
Best practice

Start every non-trivial script with set -euo pipefail. The -o pipefail flag is covered in section 4 — it closes a gap that -e alone leaves open.

Reading xtrace output: PS4 and file+line numbers

By default, set -x prefixes each traced line with +. Nested calls get extra + characters (subshells get ++, sub-subshells get +++). That is useful for call depth, but it tells you nothing about which file or line produced the output.

The PS4 variable controls the trace prefix. Set it before enabling -x to get file and line information on every trace line:

export PS4='+(${BASH_SOURCE}:${LINENO}): ' set -x # Now xtrace output looks like this: +(deploy.sh:14): export APP_ENV=production +(deploy.sh:15): rsync -av ./dist/ user@host:/var/www/ +(lib/utils.sh:8): log_message "rsync complete"

The BASH_SOURCE array holds the name of the current script (even when sourcing other files), and LINENO is the current line number. Together they give you a stack trace you can actually use.

For even richer output, include the function name with ${FUNCNAME[0]}:

export PS4='+(${BASH_SOURCE}:${LINENO}) ${FUNCNAME[0]:+${FUNCNAME[0]}(): }' set -x # Output now includes function name when inside a function: +(deploy.sh:22) run_checks(): checking host connectivity +(deploy.sh:23) run_checks(): ping -c1 $DEPLOY_HOST

To debug only a specific section of a script without flooding the entire output, toggle -x around the problem area:

#!/usr/bin/env bash # ... lots of setup code ... set -x # start tracing here run_problematic_function set +x # stop tracing here # ... rest of script ...

trap ERR for error capture

The trap command runs a handler whenever a signal or pseudo-signal fires. The ERR pseudo-signal fires whenever a command returns a non-zero exit code — which makes it ideal for catching errors even before set -e exits the script.

The simplest useful trap prints the line number of the failure:

#!/usr/bin/env bash set -euo pipefail trap 'echo "Error on line $LINENO"' ERR cp /missing/file /destination/ # Output: Error on line 5

A more complete trap captures the exit code, the line number, and the command that failed:

#!/usr/bin/env bash set -euo pipefail trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR on_error() { local exit_code=$1 local line_num=$2 local last_cmd=$3 echo "---" echo "Script failed at line ${line_num}" echo "Command: ${last_cmd}" echo "Exit code: ${exit_code}" echo "---" } # Any failing command now produces a structured error report
Tip

$BASH_COMMAND holds the command currently being executed when the trap fires. It is one of the most useful variables you are probably not using.

You can also trap EXIT to run cleanup code whenever the script ends — whether from an error, a normal exit, or a signal:

#!/usr/bin/env bash set -euo pipefail TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT # always cleans up, even on error # ... use $TMPDIR freely ... cp large_file "$TMPDIR/" process "$TMPDIR/large_file" # $TMPDIR is removed when the script exits for any reason

The 5 most common bash mistakes caught by debugging

  1. 01
    Unquoted variables

    The single most common bash bug. When you write cp $SOURCE $DEST and either variable contains a space, bash performs word splitting and the command receives the wrong arguments. set -x shows you the expanded value immediately. Fix: always double-quote variable expansions: cp "$SOURCE" "$DEST".

    # Broken — set -x reveals the split: SOURCE="my file.txt" cp $SOURCE /backup/ # + cp my file.txt /backup/ ← three arguments, not two # Fixed: cp "$SOURCE" /backup/ # + cp 'my file.txt' /backup/ ← correct
  2. 02
    Pipe error swallowing

    In a pipeline like cmd1 | cmd2, bash sets the exit code to that of the last command only — cmd2. If cmd1 fails, set -e never sees it because the pipeline "succeeded" (from cmd2's perspective). This causes set -e to silently miss real failures.

    # cmd1 fails, but the pipeline exit code is 0 (from grep) broken_command | grep "pattern" echo $? # prints 0 — looks fine, but broken_command failed
  3. 03
    Missing set -o pipefail

    The fix for mistake #2. With pipefail, a pipeline's exit code is the rightmost non-zero exit code in the pipe, or zero if everything succeeded. Combined with set -e, failed pipe stages now cause the script to exit. Add it to your standard header: set -euo pipefail.

    #!/usr/bin/env bash set -euo pipefail broken_command | grep "pattern" # Now the pipeline fails because broken_command returned non-zero # set -e sees the failure and exits the script
  4. 04
    Command-not-found silenced by ||

    A pattern like command || true is often used to prevent set -e from exiting on an expected failure. The problem: it also silences genuine errors, including typos in command names. set -x will show you the command and its exit code — check that what you intended to run actually ran.

    # Intent: ignore exit code if service is already stopped systemclt stop myservice || true # typo: systemclt not systemctl # Without -x: silently does nothing # With -x: + systemclt stop myservice # bash: systemclt: command not found # + true
  5. 05
    Integer vs string comparison

    Bash uses different operators for integers and strings. Using == for numeric comparisons does a string comparison — "10" == "9" is false (correct), but it fails subtly with leading zeros or whitespace. Use -eq, -lt, -gt for numbers inside [[ ]] or arithmetic (( )).

    # Wrong: string comparison — "10" sorts before "9" lexicographically if [[ "$count" == "9" ]]; then echo "exactly 9"; fi # Wrong in a different way: leading-zero issue version="09" if [[ "$version" == "9" ]]; then echo "match"; fi # false # Correct: arithmetic comparison if [[ "$count" -eq 9 ]]; then echo "exactly 9"; fi if (( count == 9 )); then echo "exactly 9"; fi
30 bash rescue scenarios, mapped out

Bash Unfucked covers the patterns above and 25 more — real failures with exact commands to diagnose and fix them. No searching, no guessing under pressure.

Get Bash Unfucked — $2 →

Free 10-scenario sampler on GitHub →

More from the blog

Bash Quoting: The Complete Guide (Single, Double, and No Quotes) → How to Undo a Git Commit (The Right Way) → The Git Recovery Cheatsheet →

Get notified when we ship

New Unfucked references and dev tips. No spam.