Kahibaro
Discord Login Register

Debugging scripts

Understanding Why Debugging Shell Scripts Is Different

Debugging shell scripts is trickier than debugging many other languages because:

The goal of debugging is to see what the script is actually doing (commands, values, and control flow) and stop or highlight mistakes early, instead of chasing mysterious side effects.

This chapter focuses on techniques and tools for debugging, not on writing general scripts or control structures (covered elsewhere).


Enabling Shell Debug Options

The main built‑in debugging aids in bash/sh are controlled by set options and - flags when invoking the shell.

Core Options: `-e`, `-u`, `-x`, `-o pipefail`

You can turn these on in three main ways:

  #!/usr/bin/env bash
  set -euo pipefail
  bash -euxo pipefail your_script.sh
  set -x  # later: set +x

Key options:

`-e` / `set -e` — Exit on Error

But there are exceptions where -e is ignored (by POSIX definition):

  if some_command; then   # 'some_command' failure does not exit the script
      echo "ok"
  fi
  cmd1 || echo "cmd1 failed"  # 'cmd1' failure will not exit with -e

Therefore, -e is helpful but not sufficient as a complete error‑handling strategy.

`-u` / `set -u` — Treat Unset Variables as Errors

Example:

#!/usr/bin/env bash
set -u
echo "User is: $USER"
echo "Foo: $FOO"  # exits: unbound variable FOO

This helps catch typos and forgotten variable initializations.

`-x` / `set -x` — Trace Commands

Example:

#!/usr/bin/env bash
set -x
name="Alice"
echo "Hello, $name"

Output looks like:

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

You almost never want set -x in production scripts by default; enable it selectively (see below).

`-o pipefail` — Catch Failures in Pipelines

By default, in bash, the exit status of a pipeline is the status of the last command only. With pipefail:

Example without pipefail:

false | true
echo $?    # prints 0 (success), error hidden

With pipefail:

set -o pipefail
false | true
echo $?    # prints 1 (failure)

Running Scripts in Debug Mode

Rather than editing the script to add debugging options, you can run it in a debug shell:

bash -x your_script.sh         # trace commands
bash -xe your_script.sh        # trace commands + exit on error
bash -xv your_script.sh        # also show input lines as read

Meaning of flags:

This is useful when:

Selective Debugging with `set -x` / `set +x`

Tracing the entire script can be noisy. Instead, enable it around the suspicious parts:

#!/usr/bin/env bash
echo "Starting backup"
# Enable tracing only for this block
set -x
tar czf backup.tar.gz /important/data
scp backup.tar.gz backup@server:/backups
set +x
echo "Backup complete"

This gives you visibility into just the backup portion.

You can also wrap a function:

debug_on()  { set -x; }
debug_off() { set +x; }
some_func() {
    debug_on
    # function body...
    debug_off
}

Inspecting Exit Codes and Errors

Exit codes are central to debugging shell scripts.

Checking `$?`

After running a command, $? holds its exit status:

cmd
status=$?
echo "cmd exited with $status"

Use this when:

Example:

#!/usr/bin/env bash
set -e
if ! some_command; then
    echo "some_command failed with $?"
fi

Using `trap` for Error Diagnosis

trap lets you run code on signals or special pseudo‑signals like ERR and EXIT.

Trap on `ERR`

With bash, you can print debugging info whenever a command fails:

#!/usr/bin/env bash
set -Eeu -o pipefail   # -E makes ERR propagate in functions/subshells
trap 'echo "Error on line $LINENO: command exited with status $?"' ERR
dangerous_command

This gives you line‑level error messages without manually checking every command.

Trap on `EXIT`

Run cleanup or a summary when the script ends:

trap 'echo "Script finished with exit code $?"; cleanup_temp_files' EXIT

This runs whether the script exits normally or due to set -e or exit.


Adding Debug Logging

Printing internal state at key points is often the most effective debugging strategy.

Use a Dedicated Debug Function

Instead of scattering echo everywhere, define a debug helper:

#!/usr/bin/env bash
DEBUG=${DEBUG:-0}  # set to 1 to enable
debug() {
    if [[ "$DEBUG" -eq 1 ]]; then
        # >&2 sends to stderr so logs don’t mix with normal output
        printf '[DEBUG] %s\n' "$*" >&2
    fi
}
debug "Script started"
name="Alice"
debug "Name is: $name"

Then run:

DEBUG=1 ./your_script.sh

This lets you:

Including Context: Time, Script Name, Line

Improve debug to include more context:

debug() {
    [[ "${DEBUG:-0}" -eq 1 ]] || return 0
    local ts
    ts=$(date '+%Y-%m-%d %H:%M:%S')
    printf '%s [%s:%d] %s\n' "$ts" "${BASH_SOURCE[1]}" "${BASH_LINENO[0]}" "$*" >&2
}

Here:

This greatly helps when debugging larger scripts.


Debugging Functions, Subshells, and Pipelines

Advanced scripts often use functions, subshells, and pipelines in ways that make debugging non‑obvious.

Functions and Error Propagation

With set -e, behavior in functions can surprise you. Example:

set -e
foo() {
    echo "Doing foo"
    false      # this will cause script exit if not in conditional or || / &&
    echo "This will not run"
}
foo
echo "unreachable"

While debugging, you may temporarily wrap the failing part in a conditional to inspect status:

foo() {
    echo "Doing foo"
    if ! false; then
        echo "false failed with $?"
    fi
    echo "This will run"
}

Or temporarily disable -e around suspicious areas:

set +e
some_command
status=$?
set -e
echo "some_command exited with $status"

Subshells: `(...)` vs `{ ...; }`

Subshell ( ... ):

This can hide changes you expect to persist. Example:

count=0
(
    count=5
)
echo "$count"   # still 0

When debugging unexpected variable values, search for:

Prefer redirection to avoid subshell loops when you need state:

# Bad for state: read loop in a pipe (subshell)
count=0
grep pattern file | while read -r line; do
    ((count++))
done
echo "$count"  # often 0 (subshell)
# Better: redirection, same shell
count=0
while read -r line; do
    ((count++))
done < <(grep pattern file)
echo "$count"  # correct

When debugging, mentally track which shell instance variables live in.


Using `bash -n` for Syntax Checking

To quickly check for syntax errors without executing:

bash -n your_script.sh

This catches issues such as:

It does not detect logic errors or run‑time failures.

Integrate it into your workflow:

Using `shellcheck` for Static Analysis

shellcheck is a widely used static analysis tool for shell scripts.

Running ShellCheck

Install it from your distribution or from https://www.shellcheck.net.

Then run:

shellcheck your_script.sh

It reports:

Example output (simplified):

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

Each warning has an ID (SC2045) you can look up with detailed explanations and fixes.

Suppressing or Annotating Warnings

Sometimes you know a warning is acceptable (e.g., intentional portability break). You can disable a rule locally:

# shellcheck disable=SC2045
for f in $(ls *.txt); do
    ...
done

Or disable for a block:

# shellcheck disable=SC2044
find . -name '*.txt' -print0 | while IFS= read -r -d '' file; do
    ...
done
# shellcheck enable=SC2044

Use this sparingly; usually, it’s better to fix the underlying issue.


Debugging Input/Output and Quoting Issues

Many shell bugs are due to quoting or word splitting.

See Exact Arguments with `printf '%q'`

When you’re not sure what a variable expands to:

printf 'ARG: "%s"\n' "$var"

To see shell‑escaped form (how the shell would write it):

printf 'DEBUG var=%q\n' "$var"

This reveals spaces, newlines, and special characters.

Temporarily Replace Destructive Commands

To debug scripts that modify or delete files, temporarily replace dangerous commands:

# Replace rm with echo
rm() {
    command printf 'rm would delete: %q\n' "$@"
}

Or change calls in the suspect block:

# For debugging only
# rm "$file"
echo "Would remove: $file"

This lets you inspect what would happen without risk.


Debugging with `PS4` and Enhanced Tracing

The set -x tracing format can be customized using PS4.

Custom `PS4` Prompt

By default, PS4 is + . You can include timestamp, line number, and function name:

#!/usr/bin/env bash
PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
set -x
some_function() {
    echo "Inside function"
}
some_function

Example trace line:

+ script.sh:5:some_function: echo 'Inside function'

This makes set -x output far more useful, especially in larger scripts.


Debugging with External Tools

While most debugging is done with the shell itself, a few simple external tools can help.

`strace` for System Call Tracing

strace traces system calls made by your script and its child processes.

Useful to debug:

Example:

strace -f -o trace.log ./your_script.sh

Then search for ENOENT, EACCES, or failed open() calls in the log.

`set -o xtrace` with `tee`

Sometimes you want to save the set -x output:

bash -x your_script.sh 2>trace.log

Or view it live and save:

bash -x your_script.sh 2> >(tee trace.log >&2)

This can be handy during longer or intermittent runs.


Systematic Debugging Strategy for Shell Scripts

A practical approach when a script misbehaves:

  1. Verify syntax:
   bash -n script.sh
  1. Run ShellCheck and fix obvious issues:
   shellcheck script.sh
  1. Narrow down the problem area:
    • Insert debug logs at high‑level steps.
    • Use set -x only around suspect blocks.
  2. Enable strict mode (if not already):
   set -Eeuo pipefail
   trap 'echo "Error at ${BASH_SOURCE[0]}:${LINENO}: exit $?"' ERR
  1. Inspect inputs and critical variables:
    • Print arguments and environment variables near problem code.
    • Use printf '%q\n' when dealing with paths and user input.
  2. Watch for subshells and pipelines:
    • Check whether loops or assignments happen in a subshell.
  3. Check external commands:
    • Examine their exit codes.
    • Run them manually with the same arguments.
  4. Use strace or logging for tricky I/O or permission issues.

By combining these techniques, you can reliably identify where and why shell scripts fail, even as they become more complex.

Views: 24

Comments

Please login to add a comment.

Don't have an account? Register now!