How to Debug Bash Scripts

How to Debug Bash Scripts

Every script eventually breaks in a way that isn’t obvious from just reading the code. A variable that’s empty when it shouldn’t be, a loop that runs one too many times, a quoting mistake that silently swallows an error — Bash’s forgiving syntax makes it easy to write scripts that run without complaint but don’t actually do what you meant. Debugging is the skill that turns “it’s not working” into “I know exactly which line broke and why.”

This guide covers Bash’s built-in debugging tools, from simple tracing to more advanced techniques for isolating tricky bugs.

Why Bash Scripts Are Hard to Debug

Bash doesn’t have a compiler that catches errors before execution — it interprets line by line, so a typo in a rarely-executed branch might not surface for months. Variables are untyped strings by default, so a numeric comparison against an empty variable fails silently instead of throwing a clear type error. Word splitting and globbing happen automatically unless you quote things carefully, which causes bugs that only appear with certain input values (like filenames containing spaces).

Because of this, Bash gives you several built-in mechanisms specifically for tracing execution and catching problems early.

The set Built-in: Your First Debugging Tool

set -x — Print Each Command Before Running It

#!/bin/bash
set -x

name="World"
echo "Hello, $name"

total=$((5 + 3))
echo "Total: $total"

Output:

+ name=World
+ echo 'Hello, World'
Hello, World
+ total=8
+ echo 'Total: 8'
Total: 8

Every line is prefixed with + and shows the command after variable expansion, which makes it immediately clear what values are actually being used. You can enable this for the whole script, or just a section:

set -x
# suspicious code here
set +x

set -e — Exit Immediately on Error

#!/bin/bash
set -e

echo "Starting..."
false            # this command fails
echo "This line never runs"

set -e tells Bash to stop the script the moment any command returns a non-zero exit status. This is invaluable for catching silent failures, though it has well-known quirks (it doesn’t trigger inside conditionals, and behaves inconsistently with pipelines unless combined with set -o pipefail).

set -u — Error on Undefined Variables

#!/bin/bash
set -u

echo "$undefined_variable"

Output:

script.sh: line 4: undefined_variable: unbound variable

This catches typos in variable names — one of the single most common Bash bugs — before they cause silent, hard-to-trace failures.

set -o pipefail — Catch Failures in Pipelines

By default, a pipeline’s exit status is only that of the last command. pipefail makes the whole pipeline fail if any command in it fails.

#!/bin/bash
set -o pipefail

grep "pattern" nonexistent_file.txt | sort | uniq
echo "Exit status: $?"

Combining Them All

Most production scripts start with this combination, often called “strict mode”:

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'

Setting IFS (Internal Field Separator) to newline and tab avoids unexpected word splitting on spaces, which is a frequent source of subtle bugs.

The -n Flag: Syntax Checking Without Execution

bash -n script.sh

This parses the script for syntax errors without actually running any commands — a fast first check before you even start debugging logic.

The bash -v Flag: Verbose Mode

bash -v script.sh

Prints each line of the script as it’s read, before any expansion — useful for comparing against -x output (which shows commands after expansion) to understand exactly what substitution is happening.

Debugging with trap DEBUG

You can run a command before every single line executes using a DEBUG trap — this is more granular than set -x and lets you build custom debug output.

#!/bin/bash

trap 'echo "About to run: $BASH_COMMAND"' DEBUG

x=5
y=$((x + 2))
echo "Result: $y"

Output:

About to run: x=5
About to run: y=$((x + 2))
About to run: echo "Result: $y"
Result: 7

Using PS4 to Customize set -x Output

The PS4 variable controls the prefix used by set -x. By default it’s +, but you can make it much more informative:

#!/bin/bash
export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x

greet() {
    echo "Hello, $1"
}

greet "Bash"

Output:

+ script.sh:7:main: greet Bash
+ script.sh:5:greet: echo 'Hello, Bash'
Hello, Bash

This shows the exact file, line number, and function for each traced command — extremely useful in larger scripts with multiple functions.

Debugging Variable Values

A simple but effective technique: sprinkle echo statements (or better, a dedicated debug function) at key points.

#!/bin/bash

DEBUG=${DEBUG:-0}

debug() {
    if [[ "$DEBUG" == "1" ]]; then
        echo "[DEBUG] $*" >&2
    fi
}

count=0
for file in *.txt; do
    debug "Processing file: $file"
    count=$((count + 1))
done

debug "Total files processed: $count"

Run normally, or with debug output enabled:

DEBUG=1 ./script.sh

Sending debug output to stderr (>&2) keeps it separate from the script’s actual output, so it doesn’t interfere with piping or redirection.

Checking Exit Codes Explicitly

#!/bin/bash

cp important_file.txt /backup/
if [[ $? -ne 0 ]]; then
    echo "Copy failed!" >&2
    exit 1
fi

Every command sets $? to its exit status immediately after running — checking it explicitly (rather than assuming success) catches failures that set -e might miss inside conditionals.

Using ShellCheck

ShellCheck is a static analysis tool that catches an enormous range of common Bash mistakes — unquoted variables, incorrect test operators, unreachable code, and more — before you even run the script.

shellcheck script.sh

It’s available as a CLI tool, a website, and editor plugins for VS Code, Vim, and others. Running it as part of your normal workflow catches a huge class of bugs essentially for free.

Real-World Debugging Workflow Example

#!/bin/bash
set -euo pipefail

process_logs() {
    local logdir="$1"
    local count=0

    for file in "$logdir"/*.log; do
        [[ -f "$file" ]] || continue
        count=$((count + 1))
        echo "Processing: $file"
    done

    echo "Processed $count files"
}

process_logs "/var/log/myapp"

If this script silently processes zero files, debugging steps would be:

  1. bash -n script.sh — confirm no syntax errors.
  2. shellcheck script.sh — catch quoting or logic issues.
  3. Add set -x temporarily, or run with bash -x script.sh, to see the actual glob expansion of "$logdir"/*.log.
  4. Check if the glob matched nothing (common cause: wrong directory, or nullglob not being set, causing the literal unexpanded pattern to be treated as a “file”).
  5. Add shopt -s nullglob if you want an empty glob to expand to nothing instead of the literal pattern string.

Best Practices

  • Start every non-trivial script with set -euo pipefail, then relax specific parts if you have a genuine reason to allow failures there.
  • Run shellcheck on every script before deploying it.
  • Use bash -n as a fast pre-flight check in CI pipelines.
  • Prefer targeted set -x / set +x blocks around suspicious sections rather than tracing an entire large script.
  • Log meaningful context (variable values, timestamps) rather than generic “here” markers.

Security Considerations

  • Never leave set -x debug output enabled in production scripts that handle secrets — command tracing will print sensitive values (passwords, API keys, tokens) directly to the terminal or logs.
  • Redirect debug/trace output away from world-readable log files when scripts handle sensitive data.
  • Be cautious with trap DEBUG in scripts that process untrusted input, since debug handlers execute for every command and could unintentionally expose internal state.

Optimization Tips

  • set -x has a real performance cost on scripts with large loops — disable it once you’ve isolated the bug rather than leaving it on for the full run.
  • Prefer built-in debugging (set, trap DEBUG, PS4) over adding permanent echo statements that need to be manually removed later.
  • For performance debugging specifically (not just correctness), use time script.sh or bash -x combined with date +%s%N timestamps at key points to find slow sections.

Troubleshooting Common Issues

Problem: set -e doesn’t stop the script when I expect it to. set -e has known exceptions: it doesn’t trigger inside if/while conditions, inside functions called as part of a condition, or for all commands in certain pipelines unless pipefail is also set.

Problem: My script works interactively but fails when run via cron. Cron runs scripts with a minimal environment (different PATH, no aliases, no interactive shell config). Add explicit paths and source any needed environment files at the top of the script.

Problem: Variables seem to have the wrong value only sometimes. This is often a scoping issue — check for accidental global variables inside functions, or variables set inside a subshell (like inside a pipeline) that don’t persist back to the parent shell.

Common Mistakes

  1. Leaving set -x on in production, leaking sensitive data into logs.
  2. Relying on set -e alone without understanding its exceptions.
  3. Not quoting variables, leading to word-splitting bugs that only appear with certain inputs.
  4. Debugging by adding echo everywhere instead of using set -x, trap DEBUG, or ShellCheck.
  5. Ignoring exit codes of intermediate commands in a pipeline.

Frequently Asked Questions

What’s the fastest way to find a bug in a Bash script? Run shellcheck script.sh first — it catches a large percentage of common bugs instantly. Then use bash -x script.sh to trace actual execution if the bug is still unclear.

Does set -e catch every kind of error? No. It has documented exceptions, particularly around conditionals and pipelines. Combine it with pipefail and careful exit-code checking for more reliable error detection.

Can I debug just part of a script instead of the whole thing? Yes — wrap the suspicious section in set -x / set +x, or use a DEBUG trap that only prints for specific line ranges.

Is there a Bash equivalent of a step-through debugger? bashdb is a dedicated Bash debugger that supports breakpoints and step execution, similar to gdb for C. It’s less commonly used than set -x and ShellCheck but useful for very complex scripts.

Summary

Debugging Bash scripts comes down to making the invisible visible: set -x shows you what’s actually executing, set -euo pipefail catches silent failures, trap DEBUG and custom PS4 values give you fine-grained tracing, and ShellCheck catches whole classes of mistakes before you even run the script. Building these tools into your normal workflow — rather than reaching for them only after something breaks — is what makes Bash scripting reliable at scale.

References

  • GNU Bash Manual — The Set Builtin: https://www.gnu.org/software/bash/manual/bash.html#The-Set-Builtin
  • GNU Bash Manual — Bash Startup Files: https://www.gnu.org/software/bash/manual/bash.html#Bash-Startup-Files
  • ShellCheck: https://www.shellcheck.net/
  • GNU Bash Manual — DEBUG Trap: https://www.gnu.org/software/bash/manual/bash.html#index-trap
Total
2
Shares

Leave a Reply

Previous Post
How to Monitor System Resources in Bash

How to Monitor System Resources in Bash

Next Post
How to Handle Signals in Bash

How to Handle Signals in Bash

Related Posts