Code of the Day
AdvancedAutomation

Debugging Scripts

Use set -x trace mode, bash -n syntax checks, PS4 customisation, shellcheck, and quoting/word-splitting analysis to find and fix Bash bugs.

BashAdvanced11 min read
Recommended first
By the end of this lesson you will be able to:
  • Enable and disable set -x trace mode to inspect execution
  • Check syntax without running with bash -n
  • Customise PS4 to show file, function, and line number in traces
  • Run shellcheck to catch static bugs before execution
  • Identify and fix the most common Bash pitfalls around quoting and word splitting

Shell scripts are untyped, dynamically evaluated, and composed from external commands — a combination that makes bugs subtle and surprising. The good news is that Bash ships with excellent built-in debugging tools, and the open-source shellcheck catches an enormous class of common mistakes statically, before you even run the script.

bash -n: syntax check without execution

Before running a script, check its syntax:

bash -n myscript.sh

-n parses the script and reports syntax errors, but does not execute any commands. It catches unclosed quotes, bad if/fi structure, and missing done keywords:

$ bash -n broken.sh
broken.sh: line 7: unexpected token `fi'

Run bash -n as a pre-commit check or a CI lint step — it is zero-cost and catches the dumbest class of errors instantly.

set -x: trace mode

set -x (or bash -x script.sh) prints every command before it executes, with a + prefix. This is the most direct debugging tool:

#!/usr/bin/env bash
set -x

name="Alice"
greeting="Hello, $name"
echo "$greeting"

Output:

+ name=Alice
+ greeting='Hello, Alice'
+ echo 'Hello, Alice'
Hello, Alice

You can turn tracing on and off around a specific section:

set -x          # start tracing
suspicious_function "$arg"
set +x          # stop tracing

This is useful to isolate the noisy section — a full trace of a long script is hard to read.

PS4: customising the trace prefix

By default the trace prefix is +. Setting PS4 gives you richer context — file, line number, and function name:

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

Now the trace shows exactly where each line comes from:

+(myscript.sh:14): main(): greeting='Hello, Alice'
+(myscript.sh:15): main(): echo 'Hello, Alice'

Add this PS4 to your ~/.bashrc and it is always available when you run a script with bash -x. You do not need to modify the script itself — the environment variable is inherited.

shellcheck: static analysis

shellcheck is a static analysis tool that catches bugs, antipatterns, and portability issues without running the script. It is available in most package managers:

# Install
apt-get install shellcheck      # Debian/Ubuntu
brew install shellcheck         # macOS
shellcheck myscript.sh

Example output:

In myscript.sh line 12:
for f in $(ls *.txt); do
         ^---------^ SC2045: Iterating over ls output is fragile.
                               Use globs.

shellcheck is opinionated and thorough. It catches:

  • Unquoted variable expansions that cause word splitting
  • [ ] vs [[ ]] safety issues
  • Incorrect $@ vs $* usage
  • Variables read before being set
  • Common set -e and set -u interactions

Integrate it into your CI pipeline as a required check:

# GitHub Actions example
- name: Lint shell scripts
  run: shellcheck $(find . -name "*.sh" -not -path "./.git/*")

Common pitfalls: quoting and word splitting

The most frequent Bash bugs come from unquoted expansions:

# Bug: if $filename contains spaces, rm sees multiple arguments
rm $filename               # WRONG
rm "$filename"             # correct

# Bug: loop splits on spaces within filenames
for f in $(find . -name "*.txt"); do   # WRONG
for f in $(find . -name "*.txt"); do   # WRONG — same
while IFS= read -r f; do              # correct
  # ...
done < <(find . -name "*.txt")

# Bug: empty variable expands to nothing, breaking [ ]
if [ $count -eq 0 ]; then   # WRONG if $count is empty
if [[ $count -eq 0 ]]; then  # safer (but set -u is better)
if [[ ${count:-0} -eq 0 ]]; then  # explicit default

The pattern is always the same: double-quote every variable expansion unless you specifically need word splitting or expansion.

Debugging a failing pipeline

When a pipeline fails silently, break it into steps:

# Original failing pipeline
cat huge_file.log | grep "ERROR" | awk '{print $3}' | sort | uniq -c

# Debug: run each step, check intermediate output
cat huge_file.log | head -5                              # does the file read work?
cat huge_file.log | grep "ERROR" | head -5               # does grep find anything?
cat huge_file.log | grep "ERROR" | awk '{print $3}' | head -5  # is $3 the right field?

set -e and pipelines. With set -e but without set -o pipefail, a failing command in the middle of a pipeline does not trigger an exit. This is the main reason to use set -euo pipefail together — pipefail ensures that any step's failure propagates to the pipeline's .

Check your understanding

  1. 1.
    You run bash -x myscript.sh. What does it do?
  2. 2.
    filename="my report.pdf". Which command correctly passes it as a single argument to a program?
  3. 3.
    shellcheck runs the script in a safe sandbox to detect runtime errors.

Do it yourself

# 1. Check syntax of a broken script
cat > /tmp/broken.sh << 'EOF'
#!/usr/bin/env bash
if [[ $1 = "hello" ]]; then
  echo "Hi"
# Missing fi
EOF
bash -n /tmp/broken.sh

# 2. Use set -x to trace execution
cat > /tmp/trace.sh << 'EOF'
#!/usr/bin/env bash
set -x
name="world"
echo "Hello, $name"
set +x
echo "tracing off"
EOF
bash /tmp/trace.sh

# 3. Install and run shellcheck (if available)
which shellcheck && shellcheck /tmp/trace.sh || echo "shellcheck not installed"

# 4. Observe word splitting
filename="my file.txt"
echo "With quotes: $(echo "$filename" | wc -c) chars"
echo "Without: $(echo $filename | wc -c) chars (split!)"

Where to go next

You've completed the entire Bash track — from shell basics through scripting patterns, text processing, shell mastery, and automation. You now have the tools to write portable, robust, debuggable scripts and automate production workflows. The next frontier is combining these skills with Python, JavaScript, or SQL — the other tracks in this curriculum — to build end-to-end tooling.

Finished reading? Mark it complete to track your progress.

On this page