Debugging Bash Scripts: set -x, PS4, and ShellCheck
Shell scripts fail in unhelpful ways: a variable is empty, a filename with a space splits in two, a command fails silently and the script carries on. This guide covers the tools for finding out what is really happening, from the built-in tracing in bash to ShellCheck, which catches many bugs before a script ever runs.
It builds on our first bash script, quoting, and error handling guides.
1. Check the syntax: bash -n
bash -n deploy.sh
-n parses the script without running anything and reports syntax errors:
deploy.sh: line 42: syntax error: unexpected end of file
“Unexpected end of file” almost always means an unclosed if, for, while, case, quote, or brace somewhere above line 42. It is quick and safe to run on any script, but it only finds syntax problems.
2. Trace execution: set -x
Tracing prints each command after variable expansion, which shows what is actually being run:
bash -x deploy.sh
+ target=/srv/www
+ cp -r build/ /srv/www
+ systemctl reload nginx
To trace only part of a script:
set -x
rsync -a "$src/" "$dest/"
set +x
Tracing is how you spot the classic bugs: a variable that is empty when you expected a value, a path missing a slash, or a filename splitting into two arguments.
3. A more useful PS4
The + prefix is the PS4 prompt. Make it show where each line comes from:
export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: '
bash -x deploy.sh
+ deploy.sh:12:main: target=/srv/www
+ deploy.sh:30:sync_files: rsync -a build/ /srv/www/
Now you can see the file, line number, and function for every traced command, which is invaluable in long scripts or scripts that source other files.
4. Trace to a file
Trace output goes to stderr and mixes with everything else. Send it to a file instead:
exec 5> /tmp/deploy-trace.log
BASH_XTRACEFD=5
set -x
The script’s normal output and errors still reach the terminal, and the full trace is in the log. Our I/O redirection guide explains the file descriptor syntax.
5. Make failures loud
Many bugs are not visible because the script carries on after a failure. At the top of most scripts:
set -euo pipefail
-e: exit when a command fails-u: treat unset variables as an error-o pipefail: a pipeline fails if any command in it fails
And report where it failed:
trap 'echo "error on line $LINENO: $BASH_COMMAND" >&2' ERR
Our error handling guide covers the caveats of set -e, and trap and signal handling covers cleanup on exit.
6. Print debugging, done properly
Sometimes a well-placed message is clearest. Send it to stderr, so it does not end up in output another program reads, and make it easy to switch off:
debug() { [[ ${DEBUG:-0} == 1 ]] && printf 'DEBUG: %s\n' "$*" >&2; }
debug "target is $target"
Then run with DEBUG=1 ./deploy.sh.
declare -p var prints a variable with its type and exact value, including arrays, which shows hidden whitespace and empty elements that echo hides:
declare -p files
# declare -a files=([0]="report 2026.pdf" [1]="notes.txt")
7. ShellCheck: catch bugs before running
ShellCheck reads a script and warns about common mistakes without running it. It is the single most useful tool for writing shell scripts.
sudo apt install shellcheck # Debian / Ubuntu
sudo dnf install ShellCheck # Fedora
sudo pacman -S shellcheck # Arch
shellcheck deploy.sh
In deploy.sh line 14:
cp $src $dest
^--^ SC2086 (info): Double quote to prevent globbing and word splitting.
Every warning has a code. SC2086 (unquoted variable) is by far the most common, and it is a real bug: cp $src $dest breaks as soon as a path contains a space or a wildcard. A few others worth knowing:
| Code | Problem |
|---|---|
| SC2086 | Unquoted variable: word splitting and globbing |
| SC2046 | Unquoted command substitution |
| SC2164 | cd without handling failure (`cd dir |
| SC2155 | local x=$(cmd) hides the command’s exit status |
| SC2034 | Variable assigned but never used (often a typo) |
| SC2148 | No shebang, so ShellCheck does not know the shell |
Look up any code at shellcheck.net, or in the ShellCheck wiki on GitHub.
Silencing a warning
When a warning genuinely does not apply, disable it for one line with a directive and, ideally, a reason:
# shellcheck disable=SC2086 # $flags is intentionally split into options
rsync $flags "$src" "$dest"
Use it sparingly; most warnings point at real problems.
Use it everywhere
- Editor integration: VS Code, Neovim (via LSP or a linter plugin), and most editors show ShellCheck warnings as you type; see Neovim basics
- CI and git hooks: run
shellcheckon every script in a repository before merging shellcheck -S warningto show only warnings and errors, not style notes
A debugging checklist
bash -n script.shfor syntaxshellcheck script.shand fix what it reports- Add
set -euo pipefailand anERRtrap bash -xwith a usefulPS4to see what actually runsdeclare -pon the variables that look wrong
When a script grows past a few hundred lines, consider whether it should be Python instead; the getopts guide and arrays guide cover how far bash comfortably goes.
Frequently Asked Questions
How do I see each command a bash script runs?
Run it with bash -x script.sh, or add set -x inside the script before the part you want to trace and set +x after it. Bash prints each command after expansion, prefixed by a plus sign, so you see the actual values of variables.
How do I check a script for syntax errors without running it?
Use bash -n script.sh. It parses the script and reports syntax errors such as a missing fi or done without executing any commands. It does not catch logic errors or problems that only appear at runtime.
What is PS4 in bash?
PS4 is the prompt printed before each line of set -x trace output, a plus sign by default. Setting it to include the script name, line number, and function, for example PS4=’+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: ’, makes traces far easier to follow.
What is ShellCheck?
ShellCheck is a static analysis tool for shell scripts. It reads a script without running it and warns about common bugs: unquoted variables, word splitting surprises, useless uses of commands, portability problems, and many more, each with a code such as SC2086 linking to an explanation.
How do I silence a ShellCheck warning I disagree with?
Add a directive comment on the line before the code, such as # shellcheck disable=SC2086. Use it sparingly and only when you understand why the warning does not apply, because most ShellCheck warnings point at real bugs.
Can I send set -x output to a file instead of the terminal?
Yes. Open a file descriptor to a log file and set BASH_XTRACEFD to that number, for example exec 5> trace.log then BASH_XTRACEFD=5 before set -x. The trace goes to the file while normal output and errors still reach the terminal.