06 · Debugging Scripts¶
Shell scripts fail in ways that can be hard to see just from reading the source — a variable that's empty when you expected a value, a pipeline that "succeeds" despite an inner failure, a quoting bug that only shows up on certain input. This module covers bash's built-in debugging tools.
bash -n — syntax check without running anything¶
bash -n myscript.sh
# prints nothing if the syntax is valid
bash -n broken.sh
# broken.sh: line 14: syntax error near unexpected token `fi'
-n ("no-exec") parses the script and reports syntax errors without
actually running any of it — a fast first check before you run something
you're not sure about, and a good pre-commit hook check (Level 3).
set -x — trace every command as it executes¶
#!/usr/bin/env bash
set -x # turn tracing ON
name="Ada"
greeting="Hello, $name"
echo "$greeting"
set +x # turn tracing OFF
Each traced line is prefixed with + and shows the command after
variable expansion — invaluable for seeing exactly what a variable actually
contained at the moment a command ran.
Customizing the trace prefix with PS4¶
Adding the filename and line number to PS4 makes set -x output far more
useful in scripts that source other files or call many functions.
trap ... ERR — running code when any command fails¶
#!/usr/bin/env bash
set -e
on_error() {
echo "ERROR on line $1, exit code $2" >&2
}
trap 'on_error $LINENO $?' ERR
echo "step 1"
false # this triggers the ERR trap, then set -e exits the script
echo "never reached"
trap ... ERR fires whenever a command exits non-zero (subject to the same
rules as set -e) — combined with $LINENO, it pinpoints exactly where a
script died, which is far more useful than a bare "command failed"
somewhere in a long script.
Debugging with declare -p and ${var@Q}¶
declare -p prints a variable's exact declaration, including type
(array, associative array, etc.) — great for confirming a variable actually
holds what you think it does, especially around whitespace or quoting bugs.
${var@Q} (bash 4.4+) shows a value already quoted for re-use as shell
input.
Common pitfalls checklist¶
# 1. Unquoted variables that break on spaces or empty values
for f in $files; do ... # BAD if $files can contain spaces
for f in "${files[@]}"; do ... # GOOD — use an array + quoting
# 2. Using = instead of == inside [[ ]] (both work, but == is clearer intent)
[[ "$a" == "$b" ]]
# 3. Comparing strings with -eq/-ne (those are for NUMBERS)
[[ "$a" -eq "$b" ]] # BAD if $a/$b are strings — use == / !=
# 4. Forgetting that `local var=$(cmd)` masks the command's exit code
local result
result=$(cmd) # GOOD — separate declaration and assignment
# (with `local result=$(cmd)`, `local`'s own exit
# status is what set -e sees, not cmd's)
# 5. A pipeline "succeeding" even though an early stage failed
grep "x" nonexistent.txt | wc -l # wc always exits 0 — need `set -o pipefail`
Debugging step-by-step interactively¶
# insert a breakpoint-style pause
read -p "Press enter to continue after checking state..." _
echo "resuming, var was: $suspect_var"
# print-debugging with clear markers so it's easy to grep back out later
echo "DEBUG: count=$count, status=$status" >&2
How It Actually Works¶
set -x (xtrace) doesn't just print your source lines — it has bash
reconstruct each command from its internal parsed representation after
all expansions have happened, then print that reconstruction to stderr
prefixed with $PS4 (default +), immediately before actually executing
it. That's why set -x output shows you the expanded values (actual
filenames after globbing, actual variable contents) rather than the literal
source text — it's tracing the interpreter's execution, not echoing the
script file.
bash -n performs a syntax-only parse: it runs the same parser bash
always uses to build its internal command tree, checks the tree is
well-formed, and then throws it away without executing a single command —
which is why it catches mismatched quotes or missing fi/done but can
never catch a runtime error like calling an undefined command, since that
depends on execution, not parsing.
PS4 with ${BASH_SOURCE}:${LINENO} works because bash tracks source
file and line number as live internal state as it executes, updating them
before every command — the debugger doesn't need to instrument your
script; it just reads variables bash was already maintaining as part of
normal interpretation.
Cheat sheet¶
| Tool | Purpose |
|---|---|
bash -n script.sh |
check syntax only, don't execute |
set -x / set +x |
trace commands as they run / stop tracing |
bash -x script.sh |
trace an entire script without editing it |
PS4 |
customize the set -x trace prefix (add $LINENO, etc.) |
trap 'handler' ERR |
run a handler whenever a command fails |
declare -p var |
print a variable's exact type and value |
$LINENO |
current line number |
Exercise¶
Take a script with a deliberately introduced bug — for example, one that
loops over $files (unquoted, unset) instead of a proper array — and use
bash -x plus a trap ... ERR handler with $LINENO to locate and
diagnose the failure without reading the source top to bottom. Write up the
one-line fix.