How to Debug Bash Scripts with ‘set -x’

How to Debug Bash Scripts with 'set -x'

I spent an embarrassing number of hours early in my scripting life staring at a script that “should have worked,” sprinkling echo statements everywhere trying to figure out where things went wrong. It wasn’t until a coworker glanced over my shoulder and said “just use set -x” that debugging Bash actually became manageable. In this article, I want to walk you through exactly what set -x does, how to use it effectively, and the more advanced debugging techniques that build on top of it.

What ‘set -x’ Actually Does

set -x turns on Bash’s built-in execution trace mode. Once enabled, Bash prints every command it’s about to execute — after variable expansion and substitution — to standard error, prefixed with a + sign, before actually running it. This gives you a live, line-by-line transcript of exactly what your script is doing, including the real values of variables at the time each command runs.

Step 1: A Basic Example

Save this as debug_demo.sh:

#!/usr/bin/env bash

set -x

name="World"
greeting="Hello, $name!"
echo "$greeting"

count=5
count=$((count * 2))
echo "Count is now: $count"

set +x
echo "Tracing disabled from here on."

Run it:

bash debug_demo.sh

Output:

+ name=World
+ greeting='Hello, World!'
+ echo 'Hello, World!'
Hello, World!
+ count=5
+ count=10
+ echo 'Count is now: 10'
Count is now: 10
+ set +x
Tracing disabled from here on.

Notice how each traced line shows the command after variable substitution has already happened — you see greeting='Hello, World!', not greeting="Hello, $name!". This is exactly why set -x is so useful: it shows you reality, not just the literal source code.

Explaining the Trace Output

  • The + prefix marks each traced line; if you nest function calls, Bash adds additional + characters (++, +++) to indicate the call depth.
  • Values are shown post-expansion, so you immediately see what a variable actually contained at that point, rather than guessing.
  • set +x turns tracing back off, which is useful when you only want to debug a specific section of a longer script rather than the entire thing.

Step 2: Enabling Tracing Without Editing the Script

You don’t always need to add set -x inside the script itself. You can enable it from the command line when invoking Bash:

bash -x myscript.sh

This traces the entire script from start to finish without modifying a single line of the file, which is especially useful when debugging someone else’s script, or a script you don’t want to accidentally leave in a modified state.

Step 3: Customizing the Trace Prompt with PS4

By default, the + prefix doesn’t tell you which file or line number produced each traced command, which becomes a problem in larger scripts. Bash lets you customize this using the PS4 variable:

#!/usr/bin/env bash

export PS4='+ [${BASH_SOURCE}:${LINENO}] '
set -x

x=1
y=2
echo "Sum: $((x + y))"

Output:

+ [debug_demo.sh:5] x=1
+ [debug_demo.sh:6] y=2
+ [debug_demo.sh:7] echo 'Sum: 3'
Sum: 3

This one change — adding line numbers and filenames to the trace prefix — has saved me enormous amounts of time in scripts that source multiple files or call several functions, because I no longer have to guess which line produced which trace output.

Step 4: Tracing Only Part of a Script

Turning on tracing for an entire long script produces a lot of noise. I usually bracket just the suspicious section:

#!/usr/bin/env bash

echo "Setting up environment..."
source ./config.sh

set -x
# Only this block gets traced
result=$(complex_function "$input_value")
process_result "$result"
set +x

echo "Done."

This targeted approach keeps the debugging output focused on the part of the script you actually suspect is misbehaving.

Step 5: Combining ‘set -x’ with Other Debugging Flags

set -x is often combined with other set options for more thorough debugging:

#!/usr/bin/env bash
set -euxo pipefail
  • -e exits immediately on any command failure, so a broken script stops instead of continuing with bad state.
  • -u treats references to unset variables as errors, catching typos in variable names immediately.
  • -x traces execution, as covered above.
  • -o pipefail makes a pipeline return a failure if any command within it fails, not just the last one.

Running with all four together is my default for any script I’m actively debugging, since together they surface both logical errors (via tracing) and silent failures (via -e, -u, and pipefail) that would otherwise slip through unnoticed.

Step 6: Redirecting Trace Output to a Separate File

Trace output goes to stderr by default, which means it mixes with your script’s normal error messages. For a cleaner separation, you can redirect the trace output specifically using file descriptor redirection (Bash 4.1+):

#!/usr/bin/env bash
exec 5> trace.log
BASH_XTRACEFD=5
set -x

echo "This runs normally"
ls /nonexistent_dir

Here, exec 5> trace.log opens file descriptor 5 pointing at trace.log, and setting BASH_XTRACEFD=5 tells Bash to send all set -x trace output there instead of to the normal stderr stream. This keeps your trace log completely separate from your script’s actual error messages, which is extremely useful when you need to review a trace after the fact without it being cluttered by unrelated warnings.

Real-World Use Cases

  • Diagnosing why a variable has an unexpected value somewhere in a long script, especially after several transformations or conditional branches.
  • Debugging CI/CD pipeline scripts that fail only in the CI environment but work locally, where you can enable set -x temporarily to capture a full execution trace from the build logs.
  • Understanding third-party or legacy scripts you didn’t write, by tracing execution to see exactly what commands run and in what order.
  • Catching quoting and expansion bugs, since traced output shows you exactly how Bash interpreted your quotes and variable expansions, which is often different from what the source code visually suggests.

Automation Example: Conditional Debug Mode

I like adding an optional debug flag to my scripts rather than hardcoding set -x:

#!/usr/bin/env bash
set -euo pipefail

if [ "${DEBUG:-0}" = "1" ]; then
    set -x
fi

echo "Running main script logic..."
# rest of the script

Now I can run the script normally, or turn on full tracing only when I need it:

DEBUG=1 ./myscript.sh

This pattern means the script stays quiet in normal use but gives me a full trace on demand without editing anything.

Best Practices

  • Use PS4 with ${BASH_SOURCE}:${LINENO} for any script longer than a screenful, since bare + prefixes get confusing fast.
  • Bracket set -x/set +x around suspicious sections rather than tracing an entire long script, unless you’re specifically doing a full audit.
  • Combine with set -euo pipefail for the most complete picture of what’s failing and why.
  • Consider a DEBUG environment variable pattern so tracing is opt-in rather than baked permanently into the script.
  • Redirect trace output to a dedicated file descriptor when you need a clean, isolated log of just the execution trace.

Optimization Tips

  • Tracing does add overhead, particularly in scripts with large loops, since every single command execution gets an extra print statement. Avoid leaving set -x enabled in production automation that runs frequently or at scale.
  • When debugging performance issues specifically (not logic issues), combine set -x with time around suspect sections rather than tracing the whole script, so the trace output itself doesn’t distort your sense of where time is being spent.

Troubleshooting Common Issues

Trace output is overwhelming and hard to read — Narrow the traced section using set -x / set +x pairs, and customize PS4 to include line numbers for easier scanning.

Trace output is mixed in with regular script output, making it hard to tell apart — Remember trace output goes to stderr; redirect it separately with 2> trace.log, or use BASH_XTRACEFD for even cleaner separation as shown above.

Tracing doesn’t show inside a function the way I expect — Bash traces function calls too, but nested call depth is shown with additional + characters; make sure you’re reading the indentation level correctly.

Script behaves differently with set -x enabled versus disabled — This is rare but can happen with certain timing-sensitive operations, since tracing adds minor overhead; if you suspect this, try isolating the issue with targeted echo statements instead as a sanity check.

Common Mistakes to Avoid

  • Leaving set -x permanently enabled in scripts that run frequently in production, cluttering logs and adding unnecessary overhead.
  • Not customizing PS4, making it hard to correlate trace lines with actual source code lines in anything beyond a tiny script.
  • Forgetting that trace output goes to stderr, and being confused when it doesn’t show up in a log file that only captured stdout.
  • Relying solely on set -x for debugging logic errors when a targeted echo or a proper ERR trap (see the trap article) might get you to the root cause faster for certain kinds of bugs.

Frequently Asked Questions

Does ‘set -x’ show the output of commands too, or just the commands themselves? It shows the commands being executed; the actual output of those commands (like what echo or ls prints) still appears normally, mixed in with the trace lines.

Can I use ‘set -x’ in scripts run inside a CI/CD pipeline? Yes, and it’s one of the most common ways to debug CI failures — just wrap the relevant section in set -x / set +x, or add bash -x to how the CI system invokes your script, then review the full trace in the build logs.

Is there a GUI or visual debugger for Bash instead of text tracing? Tools like bashdb exist and offer breakpoint-style debugging, but for most day-to-day scripting, set -x combined with a well-configured PS4 covers the vast majority of debugging needs without extra tooling.

Does ‘set -x’ slow down my script significantly? For typical scripts, the overhead is negligible. For scripts with very large loops (thousands of iterations), the overhead of printing a trace line per command can become noticeable, so it’s best to disable tracing before running such scripts at scale.

Summary

set -x is one of the simplest, highest-value tools in the Bash debugging toolkit. It turns invisible internal state into a visible, real-time transcript of exactly what your script does and with what values, which is often enough to spot a bug in seconds rather than after an hour of guesswork with scattered echo statements. Combine it with a customized PS4, targeted bracketing around suspicious code, and set -euo pipefail, and you’ll have a debugging setup that handles the overwhelming majority of Bash issues you’ll ever run into.

References

Total
2
Shares

Leave a Reply

Previous Post
How to Set Default Values in Bash

How to Set Default Values in Bash

Next Post
How to Create a Bash Countdown Timer

How to Create a Bash Countdown Timer

Related Posts