Linux Basic — set -x (xtrace)

4 minute read

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:

  1. The *.csv glob expanded to nothing (no CSV files in cwd)
  2. files is empty
  3. The for loop 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

Updated: