| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10 |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user; no root required |
| Scope | Bash debugging workflow: bash -n, bash -x / set -x, PS4, LINENO, BASH_XTRACEFD, declare -p, printf %q, set -v, and ShellCheck. Does not cover DEBUG/RETURN traps or extdebug; those advanced debugging hooks are outside this article's scope. |
| Related guides | Shell scripting course hub |
Bash gives you several debugging tools, but bash -x on the whole script is rarely the best first move. When a Bash script fails, start by reproducing the problem with the smallest possible input. Check the syntax, run ShellCheck, inspect suspicious variables, and then enable xtrace only around the code you need to investigate.
Quick Answer
To debug a Bash script, first run bash -n script.sh to check syntax, then shellcheck script.sh for common mistakes. Run bash -x script.sh to trace commands as they execute, or use set -x and set +x to trace only part of the script. Set PS4 with $LINENO and $BASH_SOURCE when you need file and line numbers.
The sections below follow that workflow. You will see what each tool actually prints, how to add file and function context to trace lines, how to send xtrace to its own file descriptor, and why expanded trace output can leak passwords and API tokens.
A practical Bash debugging workflow
When a script misbehaves, work through these steps in order instead of tracing everything at once:
- Reproduce the failure on a minimal input and trim unrelated code.
- Run
bash -nto catch syntax errors before execution. - Run ShellCheck for quoting, portability, and common logic defects.
- Inspect suspect values with
declare -pandprintf %qat data boundaries. - Enable selective
set -xaround the function or block that fails. - Customize
PS4so each trace line shows file, line, and function. - Send xtrace to a dedicated file descriptor with
BASH_XTRACEFDwhen stderr is too noisy.
Use bash -x or set -x when syntax checks and static analysis do not reveal the problem.
The examples use /tmp/debug-bash-lab as a disposable workspace:
mkdir -p /tmp/debug-bash-labCheck syntax with bash -n
bash -n parses the script without running ordinary commands. It catches missing fi, unclosed quotes, and other syntax problems cheaply.
Save a broken script with a missing fi:
printf '%s\n' '#!/bin/bash' 'if true; then' 'echo hi' > /tmp/debug-bash-lab/bad-syntax.shAsk Bash to parse it only:
bash -n /tmp/debug-bash-lab/bad-syntax.shSample output:
/tmp/debug-bash-lab/bad-syntax.sh: line 4: syntax error: unexpected end of fileBash reported the line after the incomplete if block and exited with status 2.
A valid script produces no output:
printf '%s\n' '#!/bin/bash' 'if true; then' 'echo hi' 'fi' > /tmp/debug-bash-lab/ok-syntax.shParse the fixed script the same way:
bash -n /tmp/debug-bash-lab/ok-syntax.sh; echo exit:$?Sample output:
exit:0bash -n does not prove runtime logic is correct — only that Bash can parse the file.
Find bugs with ShellCheck
Static analysis often catches the bug before you trace anything. ShellCheck reads the script without executing it — complementary to bash -n, which only validates Bash syntax.
shellcheck /tmp/debug-bash-lab/bad-syntax.shSample output:
In /tmp/debug-bash-lab/bad-syntax.sh line 2:
if true; then
^-- SC1046 (error): Couldn't find 'fi' for this 'if'.
^-- SC1073 (error): Couldn't parse this if expression. Fix to allow more checks.For install steps, project-wide scans, and CI integration, see Lint and format Bash with ShellCheck and shfmt.
Inspect variables with declare -p and printf %q
Xtrace shows expanded values, but sometimes you need a snapshot without tracing every line. declare -p prints variables in a form you can paste back into a shell:
bash -c 'arr=(a b); declare -p arr'Sample output:
declare -a arr=([0]="a" [1]="b")printf %q quotes a value so whitespace and special characters are visible — useful for argv and word-splitting bugs:
bash -c 'arg="hello world"; printf "arg=%q\n" "$arg"'Sample output:
arg=hello\ worldIf %q shows hello\ world but the script treated hello and world as separate words, look for a missing "$arg" quote.
Trace a script with bash -x
bash -x (or set -x inside a script) enables xtrace. Bash prints commands and their expanded arguments before execution, using stderr by default.
Save a short script:
printf '%s\n' '#!/bin/bash' 'echo hello' > /tmp/debug-bash-lab/hello.shTrace the whole file without editing it:
bash -x /tmp/debug-bash-lab/hello.shSample output:
+ echo hello
hellobash -x enables xtrace for the entire script without modifying the file.
The same trace format appears for inline commands:
bash -x -c 'x=1; echo "$x"' 2>&1 | head -3Sample output:
+ x=1
+ echo 1
1Xtrace lines use PS4, which defaults to + . In nested command substitutions or other levels of indirection, Bash repeats the first character as ++, +++, and so on.
Turn xtrace off inside a script with set +x. Shell options such as xtrace are part of the set family — see Bash set vs shopt for how set -x differs from shopt options.
Enable xtrace for only one section
Tracing an entire script floods stderr. Enable xtrace around the suspect function and disable it again when that block finishes.
Save this script to /tmp/debug-bash-lab/selective.sh:
#!/usr/bin/env bash
set +x
outer() { echo outer_start; inner; echo outer_end; }
inner() { set -x; echo inner_traced; set +x; echo inner_done; }
outerRun it:
bash /tmp/debug-bash-lab/selective.shSample output:
outer_start
+ echo inner_traced
inner_traced
+ set +x
inner_done
outer_endOnly inner ran under xtrace. The + lines appear for echo inner_traced and set +x, not for the rest of outer.
set -v vs set -x
| Mode | Command | Shows |
|---|---|---|
| verbose | bash -v script.sh |
Input as Bash reads it |
| xtrace | bash -x script.sh |
Commands after expansion |
Use -v when you want to see what Bash read; use -x when you want to see what actually runs.
Make trace output useful with PS4
PS4 is the prompt Bash prints before each xtrace line. The default is + .
Set a clearer prefix inside the shell you are tracing:
bash -c 'PS4=">> "; set -x; echo traced' 2>&1 | head -2Sample output:
>> echo traced
tracedQuote a dynamic PS4 with single quotes so variables such as $LINENO, $BASH_SOURCE, and $FUNCNAME are expanded when each trace line is printed. Double quotes would expand them when the PS4 assignment itself runs.
Add file, line and function context
For multi-file scripts, include source file, line number, and function name in PS4:
PS4='+(${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}) 'Save /tmp/debug-bash-lab/ps4-demo.sh:
#!/usr/bin/env bash
PS4='+(${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}) '
set -x
outer() { inner; }
inner() { echo inside; }
outer
set +xRun it and read the trace lines:
bash /tmp/debug-bash-lab/ps4-demo.sh 2>&1Sample output:
+(ps4-demo.sh:6:main) outer
+(ps4-demo.sh:4:outer) inner
+(ps4-demo.sh:5:inner) echo inside
inside
+(ps4-demo.sh:7:main) set +xEach + line shows filename:line:function before the expanded command. FUNCNAME[0]:-main prints main at the top level where no function name exists.
Send xtrace to a separate file with BASH_XTRACEFD
By default, xtrace shares stderr with normal error output. Set BASH_XTRACEFD to a dedicated file descriptor and open that descriptor on a log file.
Save /tmp/debug-bash-lab/trace-fd.sh:
#!/usr/bin/env bash
exec {BASH_XTRACEFD}>/tmp/debug-bash-lab/trace.log
set -x
echo traced_line
set +x
unset BASH_XTRACEFDRun the script:
bash /tmp/debug-bash-lab/trace-fd.shThe command prints traced_line on stdout with no + prefixes on stderr.
Read the trace log:
cat /tmp/debug-bash-lab/trace.logSample output:
+ echo traced_line
+ set +xexec {BASH_XTRACEFD}>file asks Bash to allocate an available descriptor instead of guessing that 19 is free. Alternatively, assign a known unused descriptor with exec 19>trace.log and export BASH_XTRACEFD=19.
Avoid BASH_XTRACEFD=2. Bash closes the configured trace descriptor when BASH_XTRACEFD is unset, so using FD 2 can accidentally close stderr. Unsetting BASH_XTRACEFD closes the trace descriptor. If xtrace is enabled again afterward, Bash writes trace output to stderr by default.
Debug functions and call stacks
LINENO is the current line number in the script. Inside a function, BASH_SOURCE, BASH_LINENO, and FUNCNAME arrays describe the call stack.
Save /tmp/debug-bash-lab/stack.sh:
#!/usr/bin/env bash
show_ctx() {
echo "FUNCNAME=${FUNCNAME[*]}"
echo "BASH_SOURCE=${BASH_SOURCE[*]}"
echo "BASH_LINENO=${BASH_LINENO[*]}"
echo "LINENO=$LINENO"
}
caller_fn() { show_ctx; }
caller_fnRun it:
bash /tmp/debug-bash-lab/stack.shSample output:
FUNCNAME=show_ctx caller_fn main
BASH_SOURCE=/tmp/debug-bash-lab/stack.sh /tmp/debug-bash-lab/stack.sh /tmp/debug-bash-lab/stack.sh
BASH_LINENO=8 9 0
LINENO=6FUNCNAME lists the current function and callers; BASH_SOURCE lists the file for each stack level. ${BASH_LINENO[0]} is the line in ${BASH_SOURCE[1]} where ${FUNCNAME[0]} was called — here line 8 in stack.sh is where caller_fn invoked show_ctx. Pair these with a custom PS4 when nested functions make plain xtrace hard to follow. For how functions are defined and called, see Bash function.
Never trace secrets
Xtrace prints commands after expansion, so variable values used in assignments or command arguments can appear in full on stderr or in your trace file.
An assignment under xtrace is enough to leak a secret:
bash -c 'set -x; TOKEN=secret; set +x' 2>&1 | head -1Sample output:
+ TOKEN=secretTurning xtrace off only around curl is not enough if the secret itself was assigned while xtrace was already enabled.
Demonstrate command-line exposure without making a network request:
bash -c 'TOKEN=secret; set -x; : curl -H "Authorization: Bearer $TOKEN" https://example.com' 2>&1 | head -1Sample output:
+ : curl -H 'Authorization: Bearer secret' https://example.comThe token is visible in the trace line even though : does not run curl. The same exposure happens when a real curl command executes. Disable xtrace before commands that touch credentials:
set +x
curl -s -H "Authorization: Bearer $TOKEN" "$url"
# Re-enable xtrace here only if it was enabled before this block.set +x prevents the expanded command from being traced. Do not blindly run set -x afterward in a reusable function, because that can enable tracing for a caller that previously had it disabled.
The same risk applies to passwords, private keys, and database URLs. Bash script logging covers production-safe logging; xtrace is a debugging tool, not a substitute for application logging.
Common debugging mistakes
| Mistake | Why it hurts | Better approach |
|---|---|---|
bash -x on the whole script first |
Huge output; secrets may leak | Follow the workflow; trace one function |
Assuming bash -n runs logic |
Only the parser runs | Add runtime checks or tests |
Leaving set -x on in production |
stderr noise and credential exposure | set +x after the debug block |
Reusing file descriptor 2 for trace |
Breaks normal stderr | Use automatic FD allocation or an unused FD |
Ignoring %q / quoting clues |
Whitespace bugs look like logic bugs | Print argv with printf %q before tracing |
For per-command failure handling after you find the bug, see Bash error handling — including automatic error-location reporting with ERR, $BASH_COMMAND, and $LINENO. For advanced tracing with the DEBUG trap and function/source-return handling with RETURN, see Bash trap. Once you isolate the slow block, wall-clock timing with time or $SECONDS quantifies the cost—see measure Bash script execution time.
Summary
Debugging Bash scripts works best as a workflow: reproduce minimally, run bash -n, run ShellCheck, inspect data with declare -p and printf %q, then enable selective set -x only where needed. Customize PS4 with BASH_SOURCE, LINENO, and FUNCNAME so trace lines identify location, and use BASH_XTRACEFD when you want xtrace in a separate log without mixing it into stderr.
Xtrace prints expanded commands, including secrets in assignments, headers, and passwords. Turn it off before handling credentials. set -v shows input lines; set -x shows commands about to execute — pick the one that matches the question you are asking.
References
- GNU Bash manual — Invoking Bash
- GNU Bash manual — The Set Builtin
- GNU Bash manual — Bash Variables (PS4, BASH_XTRACEFD)
- ShellCheck
Frequently Asked Questions
How do I run a Bash script in debug mode?
Run bash -x script.sh to trace the entire script without modifying it. To debug only part of the script, place set -x before the suspect commands and set +x after them.
Does bash -n run the script?
No. bash -n parses the script for syntax errors without executing ordinary commands. It does not run your if bodies, loops, or external programs. Use it as a fast syntax gate before runtime debugging.
Where does bash -x output go?
By default, xtrace writes to stderr. Each traced line is prefixed with PS4 (normally + ). Set BASH_XTRACEFD to a dedicated open file descriptor to send trace output elsewhere.
How do I add line numbers to trace?
Set PS4 to include ${LINENO} and optionally ${BASH_SOURCE##*/} and ${FUNCNAME[0]:-main}. Quote the assignment so those variables expand when each trace line prints, not when you assign PS4.
Can xtrace expose passwords?
Yes. Xtrace prints fully expanded commands. Tokens in curl -H headers, passwords on command lines, and secrets in variable assignments can all appear in trace output. Use set +x before those commands or avoid xtrace in production.

