Debugging Scripts
Every debugging technique in this chapter is really the same idea applied a few different ways: make Bash show you what it’s actually doing, rather than what you assume it’s doing. Given how much of this course has been about expansion happening differently than expected, that’s a genuinely powerful habit — most of the bugs covered in earlier chapters would have been obvious immediately with the right trace turned on.
bash -x — Tracing Execution From The Outside
Run any script with -x and Bash prints every command it executes — after expansion, with a + prefix — right before running it:
cat > trace-demo.sh << 'EOF'
#!/usr/bin/env bash
name="Alice"
greeting="Hello, $name"
echo "$greeting"
EOF
bash -x trace-demo.sh+ name=Alice
+ greeting='Hello, Alice'
+ echo 'Hello, Alice'
Hello, AliceNotice the trace shows greeting='Hello, Alice' — the value after expansion, quoted the way Bash itself would quote it for clarity. This is exactly the visibility that would have made every word-splitting bug in this course obvious the moment it happened: you’d see precisely what a command actually received, not what you assumed it received.
set -x — Tracing From Within A Script
bash -x traces an entire script from the outside. set -x, placed inside the script itself, turns tracing on from that point forward — and set +x turns it back off, letting you trace just a suspect section instead of the whole script:
#!/usr/bin/env bash
echo "Not traced"
set -x
suspicious_variable="value"
echo "$suspicious_variable"
set +x
echo "Not traced again"Not traced
+ suspicious_variable=value
+ echo value
value
Not traced againThis scoped approach is generally more useful than tracing an entire large script at once — it keeps the output focused on exactly the section you’re actually trying to understand.
Customizing Trace Output With PS4
The default + prefix on trace lines doesn’t tell you where in the script each line came from — for anything beyond a short script, that quickly becomes a real limitation. PS4 controls that prefix, and commonly gets set to include the source file and line number:
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
name="Alice"
echo "Hello, $name"
set +x+ trace-demo.sh:3: name=Alice
+ trace-demo.sh:4: echo 'Hello, Alice'
Hello, AliceNow every traced line points directly at its source location — genuinely useful once a script is more than a screenful long, since the plain + prefix alone gives you no way to map a trace line back to where it actually lives.
Tracing Variable Values
Beyond -x, a simpler and often just-as-effective technique is deliberately printing a variable’s value to stderr at a specific point:
echo "DEBUG: filename=[$filename]" >&2The brackets around $filename here are doing real work — they make leading, trailing, or entirely-empty values visible immediately, which a bare echo "$filename" wouldn’t reveal at a glance. This chapter’s Shell-Safety Considerations section covers exactly why the >&2 at the end matters as much as it does.
For a more structured look at a variable — including its exact type and quoting — declare -p is more precise than an echo:
name="Alice"
declare -p namedeclare -- name="Alice"Inspecting Arrays
declare -p is especially useful for arrays, where a manual loop to print each element is slower to write and easier to get subtly wrong than just asking Bash to show you the whole thing directly:
fruits=(apple "kiwi fruit" cherry)
declare -p fruitsdeclare -a fruits=([0]="apple" [1]="kiwi fruit" [2]="cherry")This immediately reveals things a casual glance might miss — here, that "kiwi fruit" is genuinely one element containing a space, not two separate elements.
Isolating Failures
When a script fails somewhere in the middle and it’s not obvious where, a few practical techniques narrow it down quickly:
- Combine
set -xwithset -e— the trace shows you exactly which command was about to run right before the script stopped, sinceset -e’s exit happens immediately after that command’s own trace line. - Bisect the script — comment out (or temporarily wrap in an early
exit) the back half of a long script to confirm whether the failure lives in the front half, and repeat, narrowing the search each time. - Extract and run the suspect section standalone, in an interactive shell, with the same variable values — this isolates whether the problem is really in that section’s logic, or in something set up earlier in the script that it depends on.
Debugging Conditionals And Expansions
Given how much of this course has centered on quoting and expansion surprises, conditionals are a common place to need this kind of visibility. Printing the exact operands right before a suspicious [[ ]] test often reveals the issue immediately:
echo "DEBUG: comparing [$status] to [active]" >&2
if [[ "$status" == "active" ]]; then
echo "Match"
fiIf $status actually holds "active " with a trailing space, or is entirely empty, the bracketed debug line makes that immediately visible — exactly the kind of hidden-whitespace or unexpectedly-empty-value issue that’s easy to stare right past in a plain echo "$status".
Best Practices
- Use
bash -xfor a quick, one-off trace without modifying the script at all — often the fastest first step when something’s behaving unexpectedly. - Use scoped
set -x/set +xpairs once you have a rough idea which section is suspect, rather than tracing an entire long script. - Set a
PS4that includes${LINENO}for any script long enough that a bare+prefix stops being useful on its own. - Reach for
declare -pover manualecholoops when inspecting a variable or array’s exact contents — it shows type, quoting, and structure in one line. - Combine
set -xwithset -especifically when trying to pinpoint which exact command triggered an unexpected early exit.
Shell-Safety Considerations
Debug output left inside a function is a genuinely common way to corrupt exactly the data-returning pattern Chapter 10 established — echo/printf plus command substitution — if that debug output isn’t sent to stderr.
Naive (broken) version:
get_greeting() {
echo "DEBUG: entering get_greeting with arg: $1"
echo "Hello, $1"
}
result=$(get_greeting "Alice")
echo "$result"DEBUG: entering get_greeting with arg: Alice
Hello, AliceCommand substitution captures all of a command’s stdout — every echo inside get_greeting, not just the one intended as its “real” return value. The debug line and the actual greeting both ended up inside result, silently corrupting exactly the data the function was supposed to hand back cleanly. Anything downstream expecting result to be a single clean line — a filename, a status string, anything used programmatically rather than just printed — would now be broken by a debug line that was only ever meant to be looked at during development.
Corrected version — send the debug line to stderr, exactly as Chapter 5 established for diagnostic output in general:
get_greeting() {
echo "DEBUG: entering get_greeting with arg: $1" >&2
echo "Hello, $1"
}
result=$(get_greeting "Alice")
echo "$result"DEBUG: entering get_greeting with arg: Alice
Hello, AliceThe output looks identical on screen — but this time, only "Hello, Alice" was ever captured into result; the DEBUG line went to stderr, visible in the terminal but entirely excluded from what command substitution collected. Any debug or diagnostic echo placed inside a function that also produces a real return value needs >&2, without exception — otherwise it’s not really “debug output” at all, it’s silently part of the function’s actual data.
Hands-On: Tracing, Inspecting, And Fixing A Debug-Output Leak
1. Set up a working directory.
mkdir -p ~/shell-course/ch21
cd ~/shell-course/ch212. Trace a small script with bash -x.
cat > trace-demo.sh << 'EOF'
#!/usr/bin/env bash
name="Alice"
greeting="Hello, $name"
echo "$greeting"
EOF
bash -x trace-demo.sh3. Scope tracing to just one section with set -x / set +x.
cat > scoped-trace.sh << 'EOF'
#!/usr/bin/env bash
echo "Not traced"
set -x
value="traced section"
echo "$value"
set +x
echo "Not traced again"
EOF
bash scoped-trace.sh4. Customize PS4 to include the source line number.
PS4='+ ${BASH_SOURCE}:${LINENO}: '
bash -x trace-demo.sh5. Inspect a variable and an array with declare -p.
name="Alice"
fruits=(apple "kiwi fruit" cherry)
declare -p name
declare -p fruits6. Reproduce the debug-output-corrupts-return-value bug, then fix it.
cat > leak-demo.sh << 'EOF'
#!/usr/bin/env bash
get_greeting() {
echo "DEBUG: entering get_greeting with arg: $1"
echo "Hello, $1"
}
result=$(get_greeting "Alice")
echo "Captured result: [$result]"
EOF
bash leak-demo.sh
cat > leak-fixed.sh << 'EOF'
#!/usr/bin/env bash
get_greeting() {
echo "DEBUG: entering get_greeting with arg: $1" >&2
echo "Hello, $1"
}
result=$(get_greeting "Alice")
echo "Captured result: [$result]"
EOF
bash leak-fixed.shCompare the two Captured result: lines directly — the first includes the corrupted, multi-line value; the second contains exactly the intended greeting.
7. Clean up.
cd ~
rm -rf ~/shell-course/ch21You’ve now traced a script both from the outside and from within, customized trace output to include real source locations, inspected variables and arrays precisely with declare -p, and confirmed directly why every diagnostic echo inside a data-returning function needs >&2. The next chapter covers real-world patterns and idioms — logging functions, lockfiles, retry-with-backoff, and the other everyday building blocks professional Bash scripts rely on.