Skip to content

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, Alice

Notice 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 again

This 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, Alice

Now 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]" >&2

The 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 name
declare -- 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 fruits
declare -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 -x with set -e — the trace shows you exactly which command was about to run right before the script stopped, since set -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"
fi

If $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 -x for 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 +x pairs once you have a rough idea which section is suspect, rather than tracing an entire long script.
  • Set a PS4 that includes ${LINENO} for any script long enough that a bare + prefix stops being useful on its own.
  • Reach for declare -p over manual echo loops when inspecting a variable or array’s exact contents — it shows type, quoting, and structure in one line.
  • Combine set -x with set -e specifically 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, Alice

Command 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, Alice

The 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/ch21

2. 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.sh

3. 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.sh

4. Customize PS4 to include the source line number.

PS4='+ ${BASH_SOURCE}:${LINENO}: '
bash -x trace-demo.sh

5. Inspect a variable and an array with declare -p.

name="Alice"
fruits=(apple "kiwi fruit" cherry)

declare -p name
declare -p fruits

6. 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.sh

Compare 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/ch21

You’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.

Last updated on