How to Debug Shell Scripts With set -x and ShellCheck
The two essential shell debugging tools: one traces exactly what a script actually executes, the other catches whole categories of bugs before it ever runs.
Shell scripts fail in ways that are often genuinely confusing to diagnose from error messages alone — these two tools cover the two halves of effective shell script debugging: tracing what actually executed, and catching bugs before execution entirely.
Step 1: enable execution tracing with set -x
#!/bin/bash
set -x
# your script's actual commands
With set -x enabled, Bash prints every command it’s about to execute, with variables and expansions already resolved, immediately before running it — showing you exactly what the script is doing, not what you assumed it would do.
Step 2: enable tracing for just a specific section
set -x
# the specific section you're debugging
set +x
Rather than tracing an entire long script (producing overwhelming output), bracket just the specific section you suspect is misbehaving with set -x and set +x.
Step 3: run a script with tracing from the command line without editing it
bash -x myscript.sh
Useful for one-off debugging without needing to add and later remove set -x from the script file itself.
Step 4: enable strict error handling to catch failures early
set -euo pipefail
-e exits immediately on any command failure rather than silently continuing; -u treats referencing an undefined variable as an error; -o pipefail makes a pipeline fail if any stage fails, not just the last one — together, a common and genuinely useful “fail loudly and immediately” default for scripts.
Step 5: install and run ShellCheck
sudo apt install shellcheck # Debian/Ubuntu
brew install shellcheck # macOS
shellcheck myscript.sh
ShellCheck performs static analysis — checking the script’s source code without ever running it — catching an entire category of well-known bugs (unquoted expansions, common globbing mistakes, unreachable code) before they ever have a chance to manifest against real input.
Step 6: understand ShellCheck’s specific warning codes
shellcheck myscript.sh
# SC2086: Double quote to prevent globbing and word splitting
Each ShellCheck warning has a specific numbered code (SC2086, in this example) — searching that exact code in ShellCheck’s own documentation gives a detailed explanation and a recommended fix, rather than needing to guess at what a shorter inline warning message means.
Step 7: integrate ShellCheck into your editor for immediate feedback
Most popular editors (VS Code, vim, Neovim) have a
ShellCheck plugin/extension providing inline warnings
as you write, rather than only when explicitly run
Catching an issue as you type is faster to fix than discovering it later via a separate manual shellcheck run — worth the small one-time setup effort for any shell scripting done regularly.
Step 8: combine both tools in your actual debugging workflow
1. Run shellcheck first — fix anything it flags before
the script has even executed once
2. If a bug remains, add set -x (or run with bash -x)
to trace exactly what the script does at runtime
ShellCheck catches structural, pattern-based issues; set -x reveals actual runtime behavior — using both together, in this order, catches the widest range of bugs with the least wasted effort.
Why these two tools cover genuinely different failure categories
ShellCheck catches bugs that are visible from the script’s source code alone — a missing quote, a common typo pattern — regardless of what input the script ever receives; set -x reveals bugs that only manifest with specific runtime data, showing you the actual expanded commands rather than the source code you wrote. Neither tool substitutes for the other; together, they cover most of what makes shell scripts hard to debug otherwise.
Where ShellCheck itself comes from, and why its codes are so specific
ShellCheck is developed by Vidar Holen (GitHub handle koalaman), with the project’s own copyright history extending back to 2012 — it began life as a remote, web-based script-checking service before becoming the local command-line tool bundled with most Linux distributions today. It’s implemented in Haskell, a deliberately different implementation choice from the shell scripts it analyzes, and each of its numbered SC warning codes maps to a specific, individually documented pitfall in the project’s own wiki — which is exactly why searching a warning code directly, rather than just reading the short inline message, reliably surfaces a fuller explanation and a concrete recommended fix.
Step 9: customize PS4 to make trace output actually readable
export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}(): '
set -x
set -x prints each traced line prefixed with the literal value of PS4 — by default just a plain +, which is nearly useless once a script traces through multiple functions or sourced files, since every line looks identical at a glance. Overriding PS4 to expand ${BASH_SOURCE} (the file), ${LINENO} (the line number), and ${FUNCNAME[0]} (the enclosing function, when any) turns undifferentiated + noise into a trace you can actually follow back to a specific line in a specific file, which matters enormously the moment a script sources any other file or defines more than one or two functions.
Step 10: send trace output somewhere other than stderr when it collides with real output
exec 5>trace.log
BASH_XTRACEFD=5
set -x
Trace output from set -x goes to standard error by default, which is usually fine — except when the script’s own error handling or a wrapping tool also reads stderr, mixing genuine error messages in with trace noise until neither is readable. Bash’s BASH_XTRACEFD variable redirects trace output to a specific file descriptor instead, keeping it fully separate from both stdout and the script’s real stderr; opening file descriptor 5 against a log file first, then pointing BASH_XTRACEFD at it, is the standard pattern for isolating a trace when a script’s own output can’t be disturbed.
Related:
- Shell Scripting Pitfalls: Quoting, Word Splitting, and Why $var Isn’t Always Safe
- How to Keep a Shell Script ShellCheck-Clean from the Start
Sources: