funwithlinux guide

Debugging Bash Scripts: Tips and Tools for Troubleshooting

Bash scripts are the workhorses of automation, powering everything from simple file backups to complex system administration tasks. However, even experienced developers can fall prey to Bash’s subtle quirks—syntax errors, unexpected variable expansion, or logic bugs that lurk in the shadows. Debugging these scripts can be frustrating, but with the right tools and techniques, you can identify and fix issues efficiently. This blog will demystify Bash script debugging, covering common pitfalls, built-in tools, advanced debugging utilities, best practices, and a real-world example. Whether you’re a beginner or a seasoned scripter, you’ll learn how to troubleshoot with confidence.

Table of Contents

  1. Common Bash Script Issues: What Goes Wrong?
  2. Built-in Bash Debugging Tools
  3. Manual Debugging Techniques
  4. Advanced Debugging Tools
  5. Best Practices to Avoid Bugs Proactively
  6. Real-World Debugging Example
  7. Conclusion
  8. References

Common Bash Script Issues: What Goes Wrong?

Before diving into debugging tools, it’s helpful to recognize the most frequent culprits behind Bash script failures. Awareness of these issues will help you spot problems faster:

1. Syntax Errors

Bash is unforgiving of syntax mistakes. Common examples include:

  • Missing closing quotes or parentheses.
  • Forgetting semicolons or do/done in loops.
  • Mismatched braces (e.g., { without }).

Example:

# Missing closing quote causes a syntax error
echo "Hello World

2. Variable Expansion Quirks

Unquoted variables can lead to unexpected word splitting or globbing (e.g., * expanding to filenames).

Example:

filename="my file.txt"
# Unquoted $filename splits into "my" and "file.txt"
cat $filename  # Error: "cat: my: No such file or directory"

3. Incorrect Loop Behavior

Looping over command output (e.g., ls *.txt) can break if filenames contain spaces or special characters.

Example:

# Bug: Splits "file with spaces.txt" into three arguments
for file in $(ls *.txt); do
  echo "Processing $file"
done

4. Subshell Side Effects

Commands run in subshells (e.g., (cd /tmp; pwd)) don’t affect the parent shell, leading to unexpected state changes.

Example:

# Subshell cd doesn't change parent directory
(cd /tmp)
pwd  # Still in original directory, not /tmp

5. Ignoring Exit Codes

Failing to check if a command succeeded (via $?) can cause scripts to proceed with invalid data.

Example:

# Script continues even if grep fails to find "error"
grep "error" log.txt
echo "Errors found!"  # Falsely claims errors exist

Built-in Bash Debugging Tools

Bash includes built-in features to help diagnose issues without external tools. These are lightweight and always available.

The set Command: Your First Line of Defense

The set command modifies shell behavior. Adding debugging flags at the start of a script (or temporarily) reveals execution details.

FlagPurpose
-x (xtrace)Prints each command and its arguments before execution (most useful).
-v (verbose)Prints input lines as they are read (good for syntax issues).
-e (errexit)Exits immediately if any command fails (non-zero exit code).
-u (nounset)Treats undefined variables as errors (avoids $undefined pitfalls).
-o pipefailMakes a pipeline return the exit code of the last failed command (not just the last command).

Example: Using set -x
Add set -x to the top of your script to trace execution:

#!/bin/bash
set -x  # Enable debugging

filename="test.txt"
echo "Creating $filename"
touch "$filename"
if [ -f "$filename" ]; then
  echo "$filename exists!"
fi
set +x  # Disable debugging

Output:

+ filename=test.txt
+ echo 'Creating test.txt'
Creating test.txt
+ touch test.txt
+ [ -f test.txt ]
+ echo 'test.txt exists!'
test.txt exists!
+ set +x

The + prefix shows commands being executed, making it easy to spot where things go wrong.

Exit Codes: Understanding $?

Every command returns an exit code (0 = success, non-zero = failure). Use $? to check the last command’s exit code.

Example:

grep "error" log.txt
if [ $? -ne 0 ]; then  # If grep failed (exit code != 0)
  echo "No errors found."
fi

Pro Tip: Combine with set -e to auto-exit on failure, preventing cascading errors.

Trap Signals for Error Handling

The trap command catches signals (e.g., errors) and runs a custom command, helping you debug crashes.

Example: Log Errors on Exit

#!/bin/bash
set -e  # Exit on error

# Trap errors and print debug info
trap 'echo "Error at line $LINENO: Command failed: $BASH_COMMAND"' ERR

cd /invalid/directory  # This will fail, triggering the trap
echo "This line never runs."

Output:

Error at line 6: Command failed: cd /invalid/directory

Manual Debugging Techniques

For quick checks or when external tools aren’t available, manual techniques can isolate issues.

Echo and Printf: The Simplest Debuggers

Insert echo or printf statements to print variable values, loop indices, or execution steps.

Example:

filename="data.csv"
echo "Debug: filename = '$filename'"  # Quotes reveal whitespace

if [ -f "$filename" ]; then
  echo "Debug: File exists"
  process_data "$filename"
else
  echo "Debug: File missing"
fi

Inspecting Variables and Subshells

Use declare -p to print a variable’s type and value (handy for arrays or special variables).

Example:

files=("file1.txt" "file2.txt")
declare -p files  # Prints: declare -a files=([0]="file1.txt" [1]="file2.txt")

To debug subshells, redirect output to a log file:

# Log subshell output to debug.log
(cd /tmp; ls -l) > debug.log 2>&1

Advanced Debugging Tools

For complex scripts, built-in tools may not be enough. These external utilities supercharge your debugging workflow.

ShellCheck: Catch Errors Before Execution

ShellCheck is a static analysis tool that flags syntax errors, bad practices, and portability issues. It’s like a linter for Bash scripts.

Installation:

  • Ubuntu/Debian: sudo apt install shellcheck
  • macOS: brew install shellcheck
  • Windows: Use WSL or download from GitHub.

Usage: Run shellcheck script.sh on your script.

Example Output:

In script.sh line 3:
for file in $(ls *.txt); do
            ^------------^ SC2045: Iterating over ls output is fragile. Use a glob.

In script.sh line 4:
  echo "Processing $file"
                   ^-----^ SC2086: Double quote to prevent globbing and word splitting.

ShellCheck explains why the code is risky and suggests fixes (e.g., use for file in *.txt instead of ls).

Bashdb: The Bash Debugger

Bashdb is a command-line debugger for Bash, modeled after gdb (GNU Debugger). It lets you set breakpoints, step through code, and inspect variables interactively.

Installation:

  • Ubuntu/Debian: sudo apt install bashdb
  • macOS: brew install bashdb

Basic Workflow:

  1. Start debugging: bashdb script.sh
  2. Set a breakpoint: break 5 (stop at line 5)
  3. Run to breakpoint: run
  4. Step to next line: next
  5. Inspect a variable: print filename
  6. Continue execution: continue

Example Session:

bashdb test.sh
bashdb<0> break 4  # Break at line 4
Breakpoint 1 set in file test.sh, line 4.
bashdb<0> run
Starting program: /bin/bash test.sh

Breakpoint 1, main() at test.sh:4
4         filename="data.txt"
bashdb<1> print filename  # Variable is undefined yet
filename = ''
bashdb<1> next  # Execute line 4
5         echo "Processing $filename"
bashdb<1> print filename
filename = 'data.txt'
bashdb<1> continue  # Run rest of script
Processing data.txt

IDEs and Editor Integrations

Modern IDEs and editors simplify debugging with built-in or plugin-based support:

  • VS Code: Install the ShellCheck and Bash Debug extensions for real-time linting and debugging.
  • JetBrains (IntelliJ, PyCharm): Enable Bash support via plugins for syntax highlighting and debugging.
  • Vim/Neovim: Use ALE (Asynchronous Lint Engine) with ShellCheck integration.

Best Practices to Avoid Bugs Proactively

Preventing bugs is easier than fixing them. Adopt these habits to write more robust scripts:

1. Use set -euo pipefail

Start scripts with #!/bin/bash followed by set -euo pipefail to catch errors early:

  • -e: Exit on command failure.
  • -u: Treat undefined variables as errors.
  • -o pipefail: Fail if any command in a pipeline fails.

2. Quote Variables Always

Quoting ("$variable") prevents word splitting and globbing.

Bad: rm $temp_files (dangerous if temp_files contains *)
Good: rm "$temp_files"

3. Write Modular Functions

Break scripts into functions with clear inputs/outputs. This isolates bugs to specific code blocks.

Example:

process_file() {
  local filename="$1"  # Local variable to avoid side effects
  if [ ! -f "$filename" ]; then
    echo "Error: $filename not found" >&2
    return 1
  fi
  # ... processing logic ...
}

4. Test Edge Cases

Validate inputs (e.g., empty files, special characters), and test with set -x to simulate real-world scenarios.

5. Comment and Document

Explain why code works, not just what it does. This helps you (and others) debug later.

Real-World Debugging Example

Let’s walk through fixing a broken script using the tools above.

The Broken Script

This script is supposed to back up .txt files to a backup/ directory, but it fails when filenames have spaces:

#!/bin/bash

# Buggy script: Fails with filenames containing spaces
mkdir -p backup
files=$(ls *.txt)  # Risky: Splits filenames with spaces

for file in $files; do
  cp "$file" "backup/$file"
  echo "Backed up $file"
done

Problem: If ls *.txt returns file 1.txt, $files becomes "file" "1.txt", and the loop processes file and 1.txt as separate files (both missing).

Debugging Steps

  1. Enable set -x to see variable expansion:
    Add set -x at the top. Run the script—output shows:

    + files='file 1.txt'
    + for file in $files
    + cp file backup/file
    cp: cannot stat 'file': No such file or directory
  2. Run ShellCheck to identify the root cause:

    shellcheck backup.sh
    In backup.sh line 5:
    files=$(ls *.txt)
           ^----------^ SC2045: Iterating over ls output is fragile. Use a glob.
  3. Fix the Loop by using a glob directly (no ls):

    # Fixed: Loop over *.txt directly to handle spaces
    for file in *.txt; do
      cp "$file" "backup/$file"
      echo "Backed up $file"
    done
  4. Test with set -euo pipefail to catch other issues (e.g., missing backup/ directory, but we already have mkdir -p backup).

The Corrected Script

#!/bin/bash
set -euo pipefail  # Exit on errors, undefined variables, or pipeline failures

mkdir -p backup

# Safe: Loop over *.txt glob (handles spaces in filenames)
for file in *.txt; do
  # Skip if no .txt files exist (prevents "*.txt" from being treated as a filename)
  [ -e "$file" ] || continue
  cp "$file" "backup/$file"
  echo "Backed up '$file'"  # Quotes show spaces in output
done

Conclusion

Debugging Bash scripts is a skill that improves with practice. By understanding common pitfalls, leveraging built-in tools like set -x, and using advanced utilities like ShellCheck and Bashdb, you can diagnose issues faster. Proactive habits—quoting variables, writing modular code, and testing edge cases—will reduce bugs in the first place.

Remember: The goal isn’t just to fix scripts, but to write resilient ones. With the right tools and mindset, you’ll turn debugging from a chore into a systematic process.

References