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:
bash -n script.sh— confirm no syntax errors.shellcheck script.sh— catch quoting or logic issues.- Add
set -xtemporarily, or run withbash -x script.sh, to see the actual glob expansion of"$logdir"/*.log. - Check if the glob matched nothing (common cause: wrong directory, or
nullglobnot being set, causing the literal unexpanded pattern to be treated as a “file”). - Add
shopt -s nullglobif 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
shellcheckon every script before deploying it. - Use
bash -nas a fast pre-flight check in CI pipelines. - Prefer targeted
set -x/set +xblocks 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 -xdebug 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 DEBUGin scripts that process untrusted input, since debug handlers execute for every command and could unintentionally expose internal state.
Optimization Tips
set -xhas 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 permanentechostatements that need to be manually removed later. - For performance debugging specifically (not just correctness), use
time script.shorbash -xcombined withdate +%s%Ntimestamps 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
- Leaving
set -xon in production, leaking sensitive data into logs. - Relying on
set -ealone without understanding its exceptions. - Not quoting variables, leading to word-splitting bugs that only appear with certain inputs.
- Debugging by adding
echoeverywhere instead of usingset -x,trap DEBUG, or ShellCheck. - 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