A colleague's bash trace from `set -x` is a wall of lines that all start with `+ ` and you cannot tell which file or line any of them came from. How do you make bash's trace show the source file, line number and function — and why must that PS4 assignment be single-quoted?
answer
- the trace prefix is a variable
- re-expanded on every trace line
- single quotes keep it a template
- BASH_SOURCE, LINENO, FUNCNAME
- first character repeats for depth
basics
~20 sSet bash's PS4 variable, which prefixes every xtrace line: PS4='+ ${BASH_SOURCE[0]}:${LINENO}:${FUNCNAME[0]:-main}: '. Single quotes are required so the expansions are stored literally and re-evaluated on each trace line; double quotes would expand them once, freezing one line number.
solid answer
~50 sBash prints the value of `PS4` in front of every `set -x` trace line, and it re-expands `PS4` each time, so you can put live location variables in it. A good one is `PS4='+ ${BASH_SOURCE[0]}:${LINENO}:${FUNCNAME[0]:-main}: '` — `BASH_SOURCE[0]` is the file currently being read, `LINENO` the line about to run, and `FUNCNAME[0]` the enclosing function, with `:-main` supplying a name at top level where `FUNCNAME` is empty. The quoting matters: with double quotes the expansions happen once, at assignment time, so every trace line reports the line number of the assignment itself. Single quotes store the `$`-expressions literally and let bash expand them per line. Note also that the *first character* of `PS4` is repeated to show nesting, so a command inside a command substitution prints `++` — keep that first character a `+` so the depth cue survives.
code
bash · 12 lines#!/usr/bin/env bash
# Single quotes: PS4 stays a template and is expanded per trace line.
PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
build() {
local tag=$1
echo "building $tag"
}
set -x
build v1.4.2
set +xgo deeper
Know that the + in front of a traced command comes from the PS4 variable and that you can change it. Recognise LINENO and BASH_SOURCE as the shell's own location variables.
Explain that bash re-expands PS4 for every trace line, and that single quotes are therefore mandatory so the expansions are deferred. Be able to write a prefix with file, line and function and say what each element resolves to.
Show judgment about the trace as an artifact other people read: a greppable prefix, a process id when several traced processes share a log, and the fork cost of a command substitution in PS4 on a loop that traces thousands of lines.
Standardise it — one agreed trace format across the estate's scripts so traces are machine-greppable, plus a rule that an inherited PS4 is executable content in someone else's shell and does not belong in a shared profile.
## PS4 is a template, not a string When xtrace is on, bash prints the expanded value of `PS4` before each traced command. "Expanded" is the operative word: `PS4` is subjected to parameter expansion, command substitution and arithmetic expansion **every time a trace line is emitted**, exactly like the interactive prompt variables. That makes it a template into which you can inject the shell's own location variables. The default value is `+ `, which carries no information beyond "this is a trace line". ## A prefix worth having ```bash PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' set -x ``` Each piece: - **`BASH_SOURCE[0]`** — an array whose element 0 is the name of the source file currently being executed. It is the reliable way to identify the file, because a script that `source`s a shell library will otherwise produce trace lines that look like they came from the main script. `${...##*/}` strips the directory so the prefix stays short. - **`LINENO`** — the line number in the current source file of the command about to run. Because `PS4` is re-expanded per line, it tracks. - **`FUNCNAME[0]`** — the function currently executing. Outside any function `FUNCNAME` is empty, so `${FUNCNAME[0]:-main}` substitutes a readable placeholder rather than printing nothing. The result turns an anonymous firehose into something greppable: ``` + deploy.sh:41:main: build_image v1.4.2 + deploy.sh:18:build_image: docker build -t app:v1.4.2 . ``` People also add a timestamp with a command substitution such as `$(date +%T)`, or the process id via `$$` when several processes trace into one stream. Anything with a command substitution costs a fork per traced line, so on a hot loop it can dominate the runtime. ## Why the quoting decides whether it works This is the classic failure: ```bash PS4="+ ${BASH_SOURCE[0]}:${LINENO}: " # WRONG ``` Double quotes do not suppress parameter expansion. The expansions run **once**, at the moment of assignment, so `PS4` becomes the fixed string `+ deploy.sh:3: ` and every single trace line for the rest of the run claims to come from line 3. The script looks traced; the location data is a lie, which is worse than no location data. ```bash PS4='+ ${BASH_SOURCE[0]}:${LINENO}: ' # RIGHT ``` Single quotes preserve the `$` sequences verbatim; bash expands them when it prints each trace line. The rule generalises: any variable whose value is meant to be re-expanded later — `PS1`, `PS4`, a format template — is assigned in single quotes. ## The nesting cue Bash replicates the **first character** of `PS4` to indicate levels of indirection: a command running one level deeper, such as inside a command substitution or a subshell, is prefixed with a doubled character. If your custom `PS4` begins with a `+`, you keep that signal (`++ deploy.sh:…`). If you begin it with a space or a timestamp, the depth information is still there but far less readable, so conventionally the `+` stays first. ## Where to set it `PS4` is an ordinary shell variable, so: - Set it in the script, right before the tracing you care about. - Or supply it on the invocation — `PS4='+ ${BASH_SOURCE[0]}:${LINENO}: ' bash -x ./script.sh` — to get a rich prefix on a script you cannot edit. It must reach the child's environment, since ordinary shell variables are not inherited. One caution about environment inheritance: a `PS4` containing a command substitution is executed by any bash that turns on xtrace, so treat an exported `PS4` the way you treat any other inherited executable content — keep it simple and local to your own debugging session. ## What it does not fix A richer prefix makes a trace navigable; it does not reduce its volume, and it does not stop the trace from printing expanded secrets, since the command itself is still printed in clear. Those are separate decisions about when tracing is on and where its output goes.
- Why use `${BASH_SOURCE[0]}` rather than `$0` in a PS4 prefix?`$0` is the name the shell was invoked with and stays the same after `source`, so trace lines produced inside a sourced library would still claim to come from the top-level script. `BASH_SOURCE[0]` names the file currently being read, so it correctly attributes lines to the library they live in.
- What is the cost of putting `$(date +%T)` into PS4?A command substitution forks a process for every traced line. On a loop that traces thousands of commands that can dominate the script's runtime and distort exactly the timing you were trying to measure. Prefer a builtin source of time, or add the timestamp only around the region under investigation.
- Why does an exported PS4 need the same care as any other inherited setting?Because `PS4` is expanded — including command substitutions — by every bash that enables xtrace and inherits it, its contents behave as code in someone else's shell. Keep an exported PS4 to plain parameter expansions, and prefer setting it inside the script or on a single command line rather than in a shared profile.
PS4 is the log format string of the shell's tracer: single quotes make it a template evaluated per line, double quotes bake one moment's values into it forever — the same difference as a log pattern versus a log line.
saying these in an interview costs you the question
- Assigns PS4 in double quotes and reports a frozen line number
- Thinks PS1 controls the xtrace prefix
- Believes PS4 is expanded only once at assignment
- Reads a doubled + as meaning the command failed
- Puts a command substitution in PS4 inside a hot loop