Debug Bash Scripts with bash -x, PS4 and LINENO

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
Short on time?Beta Get the gist of this 10-minute read in seconds, then ask follow-up questions

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:

  1. Reproduce the failure on a minimal input and trim unrelated code.
  2. Run bash -n to catch syntax errors before execution.
  3. Run ShellCheck for quoting, portability, and common logic defects.
  4. Inspect suspect values with declare -p and printf %q at data boundaries.
  5. Enable selective set -x around the function or block that fails.
  6. Customize PS4 so each trace line shows file, line, and function.
  7. Send xtrace to a dedicated file descriptor with BASH_XTRACEFD when 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:

bash
mkdir -p /tmp/debug-bash-lab

Check 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:

bash
printf '%s\n' '#!/bin/bash' 'if true; then' 'echo hi' > /tmp/debug-bash-lab/bad-syntax.sh

Ask Bash to parse it only:

bash
bash -n /tmp/debug-bash-lab/bad-syntax.sh

Sample output:

output
/tmp/debug-bash-lab/bad-syntax.sh: line 4: syntax error: unexpected end of file

Bash reported the line after the incomplete if block and exited with status 2.

A valid script produces no output:

bash
printf '%s\n' '#!/bin/bash' 'if true; then' 'echo hi' 'fi' > /tmp/debug-bash-lab/ok-syntax.sh

Parse the fixed script the same way:

bash
bash -n /tmp/debug-bash-lab/ok-syntax.sh; echo exit:$?

Sample output:

output
exit:0

bash -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.

bash
shellcheck /tmp/debug-bash-lab/bad-syntax.sh

Sample output:

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
bash -c 'arr=(a b); declare -p arr'

Sample output:

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
bash -c 'arg="hello world"; printf "arg=%q\n" "$arg"'

Sample output:

output
arg=hello\ world

If %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:

bash
printf '%s\n' '#!/bin/bash' 'echo hello' > /tmp/debug-bash-lab/hello.sh

Trace the whole file without editing it:

bash
bash -x /tmp/debug-bash-lab/hello.sh

Sample output:

output
+ echo hello
hello

bash -x enables xtrace for the entire script without modifying the file.

The same trace format appears for inline commands:

bash
bash -x -c 'x=1; echo "$x"' 2>&1 | head -3

Sample output:

output
+ x=1
+ echo 1
1

Xtrace 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:

text
#!/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; }
outer

Run it:

bash
bash /tmp/debug-bash-lab/selective.sh

Sample output:

output
outer_start
+ echo inner_traced
inner_traced
+ set +x
inner_done
outer_end

Only 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
bash -c 'PS4=">> "; set -x; echo traced' 2>&1 | head -2

Sample output:

output
>> echo traced
traced

Quote 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:

text
PS4='+(${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}) '

Save /tmp/debug-bash-lab/ps4-demo.sh:

text
#!/usr/bin/env bash
PS4='+(${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}) '
set -x
outer() { inner; }
inner() { echo inside; }
outer
set +x

Run it and read the trace lines:

bash
bash /tmp/debug-bash-lab/ps4-demo.sh 2>&1

Sample output:

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 +x

Each + 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:

text
#!/usr/bin/env bash
exec {BASH_XTRACEFD}>/tmp/debug-bash-lab/trace.log
set -x
echo traced_line
set +x
unset BASH_XTRACEFD

Run the script:

bash
bash /tmp/debug-bash-lab/trace-fd.sh

The command prints traced_line on stdout with no + prefixes on stderr.

Read the trace log:

bash
cat /tmp/debug-bash-lab/trace.log

Sample output:

output
+ echo traced_line
+ set +x

exec {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:

text
#!/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_fn

Run it:

bash
bash /tmp/debug-bash-lab/stack.sh

Sample output:

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=6

FUNCNAME 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
bash -c 'set -x; TOKEN=secret; set +x' 2>&1 | head -1

Sample output:

output
+ TOKEN=secret

Turning 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
bash -c 'TOKEN=secret; set -x; : curl -H "Authorization: Bearer $TOKEN" https://example.com' 2>&1 | head -1

Sample output:

output
+ : curl -H 'Authorization: Bearer secret' https://example.com

The 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:

text
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


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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)