Portability and Debugging · about 45 min

Debugging scripts

Why this matters

Shell scripts fail in quiet ways. A variable is empty because of a typo in its name, a file name splits into two words, a comparison is not a comparison at all, and the script just carries on printing wrong numbers. Staring at the source rarely helps, because the bug is in what the line becomes after expansion, which you cannot see in the source. bash can show you exactly that, and a few habits turn hour-long hunts into minutes.

The concept: make bash show its work

Tracing with -x

The xtrace option makes bash print every simple command to standard error, after all expansions and just before running it, prefixed with the value of PS4 (default +):

$ cat greet.sh
name="Ada Lovelace"
file=$name.txt
touch $file
$ bash -x greet.sh
+ name='Ada Lovelace'
+ file='Ada Lovelace.txt'
+ touch Ada Lovelace.txt

The trace quotes words that contain spaces, so touch Ada Lovelace.txt without quotes tells you touch received two arguments: the unquoted $file was split. That one line shows the bug.

Ways to turn it on:

HowScope
bash -x script.shthe whole run, without editing the file
set -x ... set +xjust the region between, inside the script
#!/bin/bash -xevery run (rarely a good idea)
bash -xv script.shalso echo each line as read (-v, verbose), before expansion

Tracing only the suspicious part keeps the output readable:

set -x
result=$(compute "$input")
set +x

Better trace lines with PS4

PS4 is expanded each time a trace line is printed, so it can include live information:

export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: '
$ bash -x deploy.sh
+ deploy.sh:12:main: target=/srv/app
+ deploy.sh:30:copy_files: cp -r build /srv/app

Use single quotes so the variables are expanded at trace time, not once when PS4 is assigned. LINENO is the current line number, BASH_SOURCE the file (useful with sourced libraries), FUNCNAME[0] the current function. The first character is repeated to show nesting depth: ++ is a command inside a command substitution or subshell.

To keep the trace out of the program's own error output, send it elsewhere with BASH_XTRACEFD:

exec 5> trace.log      # open file descriptor 5 on trace.log
BASH_XTRACEFD=5
set -x

Syntax check with -n

bash -n script.sh parses without executing. It finds unbalanced quotes, a missing fi or done, a stray (:

$ bash -n broken.sh
broken.sh: line 14: syntax error: unexpected end of file

"Unexpected end of file" means bash was still waiting for something to close, usually a quote, fi, done, esac or } that starts much earlier than line 14.

Inspecting values

Stopping at the first error, with a location

Combine strict mode from the robust scripts module with a trap that reports where things went wrong:

set -Eeuo pipefail
trap 'echo "error: status $? at ${BASH_SOURCE}:${LINENO}: $BASH_COMMAND" >&2' ERR

BASH_COMMAND holds the command being executed when the trap fired. -E makes the ERR trap apply inside functions too.

What happens underneath

xtrace is not a separate debugger. It is a hook in bash's main execution path: after bash has parsed a simple command and performed all expansions (parameters, command substitution, word splitting, globbing, quote removal) it has the final list of words it is about to pass to a builtin or to execve. With -x set, it first expands PS4, then prints those words, quoting any that would not survive being re-read as shell input. That is why the trace is the ground truth about word splitting: it is literally the argument vector the command receives.

Because tracing happens per simple command, compound commands appear as their pieces: you see the condition of an if and the commands of the branch taken, but not the if keyword itself. Assignments are shown too, and redirections are not: echo hi > out.txt traces as + echo hi. Keep that in mind when output "disappears".

A method, not just tools

  1. Reproduce reliably, with the smallest input that fails.
  2. Read the actual error message. line 12: [: too many arguments says the test on line 12 received more words than it expected: an unquoted variable containing spaces.
  3. Trace the region around the failure; compare each traced line with what you expected.
  4. Check one assumption at a time: does the file exist (ls -l -- "$f")? Is the variable what I think (declare -p)? Does the command behave like that on its own (run it by hand)?
  5. Fix, then re-run the original failing case and a passing case.

Common mistakes and the messages they produce

MessageUsual cause
[: too many argumentsunquoted variable with spaces inside [ ]
[: =: unary operator expectedan empty unquoted variable vanished: [ $x = y ] became [ = y ]
syntax error: unexpected end of fileunclosed quote, if, loop, case or {
syntax error near unexpected token 'fi'missing then, or ; before then on one line
$'\r': command not foundthe script has Windows line endings; fix with sed -i 's/\r$//' f
bad substitutiontypo inside ${...}, or a bash feature run by sh
a file named 0 or 100 appears[ $a > 0 ]: > redirected; use -gt or (( ))
nothing, wrong resulttypo in a variable name: set -u would have caught it

Exercises

Two quizzes on the tools, a task producing a trace with line numbers, and a broken script with three bugs to find and fix using bash -x.

Recap

Going further: help set lists -x, -v, -n, -u and -E. Try PS4='+ $(date +%s.%N) ${LINENO}: ' bash -x yourscript.sh to get timestamps, a cheap way to find the slow line in a script.

Exercises

Exercise 1 · quiz

Check without running

You have just edited a long script that deletes old files, and you want bash to check it for syntax errors (an unclosed if, a missing done) without executing any command. Which invocation does that?

Answer
  • bash -n cleanup.sh

-n (noexec) makes bash read and parse the whole script but run nothing. It catches syntax errors only: a misspelled command or a wrong variable name is perfectly valid syntax. -x traces commands as they run, -v echoes lines as they are read (and also runs them), and -e exits on the first failing command. Making bash -n a habit before running destructive scripts costs nothing.

Exercise 2 · quiz

Reading a trace

Running bash -x on a script shows these lines:

+ today=2026-11-01
++ ls /var/log
++ wc -l
+ count=37

What does the doubled ++ tell you?

Answer
  • Those commands ran one level deeper, here inside the command substitution that computed count.

The first character of PS4 (by default +) is repeated once per level of nesting: commands inside $( ), subshells and similar indirections get an extra +. Here ls /var/log | wc -l ran inside $( ), and then the assignment count=37 ran at the top level with the result. Also note that the trace shows each command after expansion: you see the values, not the variable names, which is exactly what makes it useful.

Exercise 3 · task in a shell

A trace with line numbers

/work/tracedemo.sh is a short script. In the shell, run it with tracing enabled and a PS4 that shows the script name and line number, and save the trace (which goes to standard error) into /work/trace.txt. Each trace line should start like + tracedemo.sh:4:, that is: a plus, a space, the script name, a colon, the line number, a colon and a space.

Hint: you can set PS4 for just one command by putting PS4='...' in front of bash -x ....

Hint 1

bash -x tracedemo.sh 2> trace.txt captures the default trace. Now change the prefix.

Hint 2

PS4 must be in single quotes so ${LINENO} is expanded for each traced line, not once when you type it.

Hint 3

PS4='+ ${BASH_SOURCE}:${LINENO}: ' bash -x tracedemo.sh 2> trace.txt

Reference solution
PS4='+ ${BASH_SOURCE}:${LINENO}: ' bash -x tracedemo.sh 2> trace.txt

Exercise 4 · task in a shell

Debug a broken report

/work/report.sh should add up the numbers in each file of /work/data and print a report. Running bash report.sh in /work should print exactly:

feb extra.txt: 30
jan.txt: 60
mar.txt: 5
total: 95

It does not. There are three bugs. Use bash -x report.sh to watch what really happens, then fix the script in place (with nano report.sh, for example). Keep the output format exactly.

If running the broken version created a stray file in /work with a strange name, the trace will tell you why; you can delete it.

Hint 1

Run bash -x report.sh and read the for f in ... line: how many words does the loop get, and what are they?

Hint 2

Three bugs: looping over $(ls data) splits feb extra.txt; a variable name is misspelled in the total; and inside [ ], > is a redirection, not greater-than.

Hint 3

Loop with for path in data/*, strip the directory with ${path#data/}, fix totl, and compare with (( total > 100 )) or [ "$total" -gt 100 ].

Reference solution
cat > report.sh <<'EOF'
#!/bin/bash
# Sum the numbers in every file in data/ and print a report.
total=0
for path in data/*; do
  f=${path#data/}
  sum=0
  while read -r n; do
    sum=$((sum + n))
  done < "$path"
  echo "$f: $sum"
  total=$((total + sum))
done
if (( total > 100 )); then
  echo "total: $total (over budget)"
else
  echo "total: $total"
fi
EOF