Linux Basic — set -x (xtrace)
set -x (xtrace)
set -x makes Bash print every command before it runs, with variable expansions resolved. It’s the closest thing Bash has to a debugger, and it’s almost always the right first move when something isn’t working.
Basic Usage
#!/usr/bin/env bash
set -x
name="Alice"
echo "Hello $name"
files=(a.txt b.txt)
for f in "${files[@]}"; do
cp "$f" /tmp/
done
Output:
+ name=Alice
+ echo 'Hello Alice'
Hello Alice
+ files=(a.txt b.txt)
+ for f in "${files[@]}"
+ cp a.txt /tmp/
+ for f in "${files[@]}"
+ cp b.txt /tmp/
Each traced line is prefixed with +. Variables are expanded, so you see exactly what was passed to each command.
Equivalents: set -o xtrace, bash -x script.sh (next page).
Disable Mid-Script — set +x
set -x
some_visible_thing
set +x
secret_thing_we_dont_want_traced
set -x
more_visible_things
+ turns flags off, - turns them on. Use this to silence the noisy parts (and crucially, lines that touch secrets).
Customise the PS4 Prompt
The + prefix is controlled by PS4. Customize it to show file, line, and function:
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
Now your trace looks like:
+ script.sh:5:main: name=Alice
+ script.sh:6:main: echo 'Hello Alice'
A more elaborate prompt with timestamp:
export PS4='+ [\D{%H:%M:%S}] ${BASH_SOURCE##*/}:${LINENO}: '
set -x
Output:
+ [09:15:42] script.sh:5: name=Alice
+ [09:15:42] script.sh:6: echo 'Hello Alice'
PS4 is evaluated as a prompt string, so \D{…} time formatting works (Bash 4+).
Trace Just One Block
Use a subshell so the trace doesn’t leak:
(
set -x
risky_function
)
Or save/restore:
case "$-" in *x*) was=on ;; *) was=off ;; esac
set -x
risky_function
[[ "$was" == "off" ]] && set +x
$- is the string of currently-set short flags — useful when writing a helper that may or may not need to undo +x.
Trace + Strict Mode Combo
#!/usr/bin/env bash
set -euxo pipefail
Adding x to your strict-mode line gives you “fail-fast + trace” in one move. Great for debugging a script you’re actively rewriting. Remove the x once everything works:
set -euo pipefail
Hide a Secret From the Trace
If you absolutely must run a command with a secret while tracing:
{ set +x; } 2>/dev/null
curl -H "Authorization: Bearer $TOKEN" "$url"
set -x
The { set +x; } 2>/dev/null trick suppresses the trace of the set +x line itself (which would otherwise show “+ set +x” — fine but visually noisy).
Better: never let secrets reach the trace at all. Read them from files or env vars without echoing them:
curl --header @auth.txt "$url" # token in a file, not on the command line
Trace Output Goes to Stderr
set -x
echo hello
# Stderr: + echo hello
# Stdout: hello
This matters when you redirect:
./script.sh > out.log 2> trace.log
You get clean stdout and the trace in a separate file. Or merge them:
./script.sh > combined.log 2>&1
For a separate trace file regardless of how the script was called:
exec 3>>trace.log # open fd 3 to the trace file
BASH_XTRACEFD=3 # tell Bash to send xtrace to fd 3
set -x
BASH_XTRACEFD is the right way to keep trace output out of stderr proper.
Trace Inside Functions
By default, set -x traces commands as they execute, but it doesn’t auto-enable inside functions called normally — it carries through:
fn() {
echo "in fn"
}
set -x
fn
# + fn
# + echo 'in fn'
# in fn
If you set -x inside a function, it traces from that point on within the function:
fn() {
set -x
echo "in fn (traced)"
set +x
echo "not traced"
}
A Real Debugging Session
Suppose a script silently produces no output:
#!/usr/bin/env bash
files=$(ls *.csv)
for f in $files; do
echo "processing $f"
head -1 "$f"
done
Run with bash -x:
+ ls '*.csv'
ls: cannot access '*.csv': No such file or directory
+ files=
+ for f in $files
Three things visible immediately:
- The
*.csvglob expanded to nothing (no CSV files in cwd) filesis empty- The
forloop has zero iterations — that’s why no output
Fix: use an array and nullglob:
shopt -s nullglob
files=( *.csv )
(( ${#files[@]} )) || { echo "No CSV files" >&2; exit 1; }
for f in "${files[@]}"; do
head -1 "$f"
done
Tracing immediately revealed the empty-glob bug. That’s the value of set -x.
Common Mistakes
| ❌ Wrong | ✅ Right | Issue |
|---|---|---|
Leaving set -x in production scripts |
toggle via DEBUG=1 env var |
trace output pollutes logs |
| Tracing a command with a secret | { set +x; } 2>/dev/null around it |
secrets land in trace logs |
| Setting PS4 inside the trace itself | export PS4 before set -x |
first line still uses default |
| Mixing xtrace and stdout redirection blindly | use BASH_XTRACEFD |
trace and data get merged |
Assuming + is fixed |
PS4 is customizable | many tools depend on the literal + — be aware |
Next → bash -x Flag