---
title: 'Script Debugging'
source: 'https://academia.sh/en/courses/shell-programming/script-debugging'
course: 'Shell Programming'
language: en
updated: '2026-08-17T18:10:00+00:00'
license: 'CC BY-SA 4.0'
---

# Script Debugging

Syntax checking, trace mode and prompt formatting, redirecting the trace, and inspecting variables and the call stack.

The script terminates cleanly and leaves no trace behind. How to find out it
is producing the wrong result is a separate question.

Two features make debugging shell scripts hard. First, what actually runs is
mostly not the shell itself but the programs it calls; second, expansion
rules can make the result different from the text that was written. The
second point is decisive: you cannot know what the line `rm "$dir/$name"`
deletes without seeing how the shell expanded that line.

This lesson's tools make exactly that expanded form visible.

## Checking Without Running

`bash -n` parses the script but runs no command. It was introduced in the
Script Anatomy lesson; it is the first step for scripts that contain
destructive operations.

The script under test, `broken.sh`, contains an unclosed condition:

```sh
#!/usr/bin/env bash
# log summary — contains a syntax error
log="$1"

if [ -f "$log" ]; then
  echo "line count: $(wc -l < "$log")"
```

```sh
bash -n broken.sh
```

```
broken.sh: line 7: syntax error: unexpected end of file from `if' command on line 5
```

The error message gives two line numbers: where the problem was noticed, and
where the structure that stayed open began. The second is usually more
useful.

`-n` sees only syntax. Nonexistent commands, wrong options, undefined
variables, and logic errors pass this check. A script coming out clean with
`bash -n` does not mean it will run.

## Trace Mode

The `set -x` option writes every command to standard error **after**
expansions and **before** execution. It can also be turned on for the whole
script with `bash -x script`.

```sh
bash -x extract.sh
```

```
+ log=access.log
+ count=3
+ top_paths access.log 3
+ cut '-d ' -f7 access.log
+ sort
+ uniq -c
+ sort -k1,1nr
+ head -3
```

Three pieces of information are visible here. The variables took their
expected values. The function was called with the arguments `access.log` and
`3`. The pipeline's links were listed separately, and the `3` in the
`head -3` line is the variable's expanded form.

Notice the `'-d '` notation on the fourth line: the shell shows arguments
that require quoting inside quotes in the trace output. This is the most
direct evidence for whether an argument containing a space really passed
through as a single piece.

## Formatting the Trace Prompt

The default `+ ` prefix does not say which line is running in a long script.
The `PS4` variable sets this prefix, and it is expanded before every line is
printed.

```sh
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
bash -x extract.sh
```

```
+ extract.sh:2:main: log=access.log
+ extract.sh:3:main: count=3
+ extract.sh:7:main: top_paths access.log 3
+ extract.sh:5:top_paths: cut '-d ' -f7 access.log
+ extract.sh:5:top_paths: sort
+ extract.sh:5:top_paths: uniq -c
+ extract.sh:5:top_paths: sort -k1,1nr
+ extract.sh:5:top_paths: head -3
```

Every line now carries the file name, line number, and function name. The
`PS4` assignment is written inside single quotes: the variables must expand
not at assignment time, but each time a trace line is written.

`FUNCNAME[0]` is undefined at the top level; the `:-main` default value
makes it work under `set -u` as well.

## Selective Tracing

Tracing the whole of a long script leaves the line you are looking for
buried inside thousands of lines of output. With the `set -x` / `set +x`
pair, only the section of interest is traced.

```sh
#!/usr/bin/env bash
echo "untraced section"
set -x
code=404
class="${code:0:1}"
set +x
echo "result: ${class}xx"
```

```
untraced section
+ code=404
+ class=4
+ set +x
result: 4xx
```

The closing line falls into the trace output too; the closing itself is a
command being executed.

## Separating the Trace

Trace output goes to standard error. Since the script's warnings go there
too, the two get mixed, and in a scheduled job that logs the script's
output, it becomes unreadable.

The `BASH_XTRACEFD` variable redirects the trace to a different descriptor:

```sh
#!/usr/bin/env bash
exec 9> trace.log
BASH_XTRACEFD=9
set -x
code=404
class="${code:0:1}"
set +x
echo "report output: ${class}xx"
```

```
report output: 4xx
```

`trace.log` contents:

```
+ code=404
+ class=4
+ set +x
```

Only the report itself landed on the terminal. The pattern of opening a
descriptor with `exec`, introduced in the Redirection lesson, finds its use
here: numbers 3 and above are free for this kind of use.

## Verbose Mode

`set -v` writes commands **before** expansion, as they appear in the source
file.

```sh
#!/usr/bin/env bash
set -v
code=404
echo "class: ${code:0:1}xx"
```

```
code=404
echo "class: ${code:0:1}xx"
class: 4xx
```

`-x` shows the expanded form, `-v` shows the written form. When both are
turned on together, an expansion's input and output are seen side by side;
this is the most direct diagnostic route when expansion rules are
misunderstood.

## The Hook That Runs Before Every Command

`trap ... DEBUG` runs before every simple command. The `BASH_COMMAND`
variable holds the text of the command about to be executed.

```sh
#!/usr/bin/env bash
trap 'printf "[%s] %s\n" "$LINENO" "$BASH_COMMAND" >&2' DEBUG
code=404
class="${code:0:1}"
echo "result: ${class}xx"
```

```
[3] code=404
[4] class="${code:0:1}"
[5] echo "result: ${class}xx"
result: 4xx
```

The `DEBUG` hook provides something `set -x` cannot: its body can be
**arbitrary code**. Stopping when a specific variable jumps to an unexpected
value, or printing a variable's value before every command, is done this
way.

The cost is cost itself: the hook runs for every command. It is not left in
production scripts.

## Inspecting Variables

`declare -p` prints a variable's type and content in a reusable form. `echo`
is not enough for arrays; `declare -p` shows element boundaries.

```sh
codes=(200 404 500)
declare -A counts=([404]=4 [500]=3)
declare -p codes counts
```

```
declare -a codes=([0]="200" [1]="404" [2]="500")
declare -A counts=([404]="4" [500]="3" )
```

`-a` shows the indexed array, `-A` the associative one. Only in this output
can you see that indices skip in a sparse array, or that an empty element is
genuinely empty.

## Reading the Call Stack

Bash keeps the call chain in three parallel arrays: `FUNCNAME` holds
function names, `BASH_SOURCE` file names, `BASH_LINENO` call lines. Printing
these in an error handler shows where the error came from.

```sh
where() {
  local i
  for (( i = 1; i < ${#FUNCNAME[@]}; i++ )); do
    printf '  %s() <- %s:%s\n' "${FUNCNAME[i]}" "${BASH_SOURCE[i]}" "${BASH_LINENO[i-1]}"
  done
}
inner() { where; }
outer() { inner; }
outer
```

```
  inner() <- ./stack.sh:8
  outer() <- ./stack.sh:9
  main() <- ./stack.sh:10
```

The chain is read from the inside out: the call to `where` came from inside
`inner`, the call to `inner` from `outer`, the call to `outer` from the top
level. The index shift (`BASH_LINENO[i-1]`) is deliberate; the three arrays
hold different facets of the same event with different alignments.

When this function is added to the `ERR` hook set up in the previous lesson,
the script reports not only the error's line but also its **call path**.

## A Method

The order of the tools also gives a method for debugging:

1. Get the syntax past `bash -n`.
2. Add the strict mode header; most errors become visible on their own.
3. Narrow down which section the problem is in — placing a temporary `exit`
   at the end of a section and bisecting is the fastest way.
4. Trace the narrowed-down section with `set -x` and add location
   information with `PS4`.
5. If you are looking for a difference between an expansion's input and
   output, turn it on together with `-v`.
6. Print complex data structures with `declare -p`.

This order puts the cheapest tool first. A session that starts with
`set -x` can end up spending its time hunting for a syntax error that
`bash -n` would have found in a second.

## Summary

- `bash -n` checks only syntax; it does not run the script and does not see
  logic errors.
- `set -x` writes every command after expansions; it is direct evidence of
  whether quoting is working correctly.
- `PS4` sets the trace prefix and is re-expanded for every line; it must be
  assigned inside single quotes.
- `BASH_XTRACEFD` sends trace output to a separate descriptor, separating it
  from the script's own messages.
- `set -v` shows the written form, `set -x` the expanded form; used
  together, an expansion's input and output can be compared.
- `declare -p` gives array contents with their boundaries, and
  `FUNCNAME`/`BASH_SOURCE`/`BASH_LINENO` give the call stack.

## Next Step

This lesson's tools find an error after it has occurred. Most errors in
shell scripts, though, are determined before they occur: an unquoted
expansion, an unchecked exit code, a non-portable option. The next lesson
takes up the static analysis tool that catches this class without running
the script, and puts `report.sh` through its inspection.
