Mastering Bash Script Debugging: Essential Techniques for Developers
Debug Bash scripts with syntax checks, xtrace, strict mode, traps, ShellCheck, and focused logging.
Mastering Bash Script Debugging: Essential Techniques for Developers
Bash script debugging starts with one question: where did the script do something different from what you expected? Good debugging gives you visibility into syntax errors, expanded variables, command order, and exit statuses without turning the script into noise.
This guide walks through practical Bash debugging techniques you can use on a real automation script before it reaches cron, CI, or production.
Start With a Syntax Check
Before you trace runtime behavior, make sure Bash can parse the file:
bash -n ./deploy.sh
bash -n reads the script and reports syntax errors without running commands. It catches missing fi, done, then, quotes, and braces. It will not catch logic errors, missing files, or commands that fail at runtime.
For example, this typo is caught before anything runs:
if [ -f "$CONFIG" ]; then
echo "Config found"
# missing fi
Run a syntax check after large edits and before adding more debugging output.
Trace Execution With set -x
The most useful built-in debugger is xtrace:
set -x
some_command "$VALUE"
set +x
With tracing enabled, Bash prints each command after expansions and before execution. That helps you see whether a variable is empty, whether a glob expanded, or whether a command received different arguments than you expected.
For whole-script tracing, run:
bash -x ./deploy.sh
For cleaner traces, set PS4 so each line includes the source line number:
export PS4='+ ${BASH_SOURCE}:${LINENO}: '
bash -x ./deploy.sh
If your script handles secrets, do not trace sections that print tokens, passwords, or signed URLs. Turn tracing off before those commands:
set +x
login_with_secret "$API_TOKEN"
set -x
Add Strict Mode Carefully
These options catch common failures earlier:
set -euo pipefail
set -e exits on many unhandled command failures. set -u treats unset variables as errors. set -o pipefail makes a pipeline fail if any command in the pipeline fails, not only the last command.
They are useful, but they are not a substitute for explicit handling. Commands such as grep may return 1 for a normal "not found" result:
if grep -q "READY" status.txt; then
echo "ready"
else
echo "not ready"
fi
That is clearer than hiding the result with grep -q "READY" status.txt || true.
Print the Right Values
Focused logging beats scattered echo lines. Print the values that affect the branch you are debugging:
printf 'DEBUG: user=%q env=%q target=%q\n' "$USER_NAME" "$ENVIRONMENT" "$TARGET_HOST" >&2
printf '%q' shows shell-escaped values, which makes spaces and special characters easier to spot. Send debug output to stderr so normal script output remains usable in pipelines.
When a command fails, capture its status immediately:
run_migration
status=$?
if [ "$status" -ne 0 ]; then
echo "Migration failed with exit code $status" >&2
exit "$status"
fi
Do not run another command before saving $?, because even echo replaces it.
Debug Loops and Conditionals
Loop bugs often come from word splitting or unexpected input. Quote variables and read lines safely:
while IFS= read -r line; do
printf 'line=%q\n' "$line" >&2
done < input.txt
For conditionals, print the exact values being compared:
printf 'expected=%q actual=%q\n' "$EXPECTED" "$ACTUAL" >&2
if [[ "$ACTUAL" == "$EXPECTED" ]]; then
echo "match"
fi
If you need to pause inside a script during local debugging, read works:
read -r -p "Press Enter to continue..."
Remove pauses before committing the script, especially if it may run unattended.
Use ShellCheck for Static Analysis
ShellCheck catches many issues Bash will happily run until they break in a corner case:
shellcheck ./deploy.sh
It flags unquoted variables, unreachable code, suspicious tests, unused variables, and portability problems. Treat warnings as prompts to inspect the code, not as automatic proof that the script is wrong. Sometimes you may intentionally disable a warning, but add a short comment explaining why.
Use trap to See the Failing Line
For longer scripts, an error trap can tell you where a failure occurred:
set -Eeo pipefail
trap 'echo "Error on line $LINENO: $BASH_COMMAND" >&2' ERR
set -E helps the ERR trap propagate into functions and subshells in Bash. This is useful in CI logs where you may not have an interactive shell.
Takeaway
Start with bash -n, use bash -x or targeted set -x for runtime tracing, and add focused stderr logging around the branch that behaves incorrectly. For scripts that matter, run ShellCheck and add an ERR trap so failures point to the command and line that need attention.