A misbehaving script needs to be dissected, not guessed at. This episode covers bash -x to trace execution, bash -n to check syntax, PS4 for context-rich traces, and trap DEBUG as a manual breakpoint. Ends with practice debugging a deliberately broken script from start to root cause.

In episode 19 we covered Signals Handling & Traps — how scripts catch signals and clean up after themselves on exit — so you now have scripts that not only stop honestly, but also leave the room neatly. However, there's one moment no programmer can avoid: the script runs, but the result is wrong. No error, no crash — just behavior that doesn't match expectations. A file that should be created isn't, a value that should be set is empty, or a sequence that should happen doesn't.
At that point, many people start guessing: adding echo here and there, fiddling with the order, praying to the terminal gods. That trial-and-error approach is slow, frustrating, and — most dangerously — often fixes the symptom without finding the root cause. Exactly like a mechanic replacing the spark plugs when the ignition wiring is broken: the car runs briefly, then breaks again.
This episode 20 is a course in script autopsy: systematic techniques to see what BASH actually does, not what you imagine it does. We'll dissect bash -x (trace mode), bash -n (syntax checking), bash -v (verbose), PS4 to enrich traces with filename, line number, and function name, plus trap ... DEBUG as a manual breakpoint for structured printf-style debugging. At the end of the episode, you'll autopsy a script we deliberately broke — step by step, until the root cause is caught.
bash -n: Check Syntax Without Running AnythingThe first debugging step should always be the cheapest: make sure the script has no syntax errors. BASH provides noexec mode with -n — BASH reads the whole script, checks syntax, and without executing a single line. It's like checking the blueprint before starting construction:
bash -n backup.sh
echo "exit: $?"If there's no output, the syntax is valid and the exit code is 0. If there's an error, BASH prints its location — and here's where -n shines: a still-dangerous script (for example one containing rm -rf) won't be run. You can safely check production scripts:
backup.sh: line 12: syntax error near unexpected token `done'
backup.sh: line 12: doneNote
bash -n is a lifesaver when you have to check a script running on a production server and don't dare run it. It also catches classic errors like unbalanced curly braces, missing fi/done, or never-closed here-documents — the "mysterious" cause of many scripts that suddenly stop reading input. Make bash -n namaskrip.sh a habit before a script is considered done.
bash -v: See the Lines Being ReadIf -n only checks, -v (verbose) prints every line of the script before executing it — like a teacher reading a question out loud before you answer. It shows the order of BASH's reading, useful for detecting structure you didn't expect (for example a block that never gets reached because of wrong logic):
bash -v deploy.shThis mode is rarely used alone — the most common combination is bash -vx to see the lines being read along with the expansions happening. But know it exists; in weird cases where a script "stops reading" midway, -v shows the exact point where BASH stopped moving forward.
bash -x: Trace Mode — Seeing Real ExpansionsThis is the main weapon of BASH debugging. xtrace mode (-x) prints every command that will run after expansion, prefixed with the marker +. With this, you see the actual values BASH runs — not what you wrote in the code:
#!/bin/bash
for file in /var/log/nginx/*.log; do
size=$(stat -c%s "$file")
echo "$file: $size bytes"
donebash -x size-check.sh+ for file in /var/log/nginx/*.log
+ size=$(stat -c%s /var/log/nginx/access.log)
++ stat -c%s /var/log/nginx/access.log
+ size=482739
+ echo /var/log/nginx/access.log: 482739 bytes
/var/log/nginx/access.log: 482739 bytesRead the trace from top to bottom: BASH prints + for file in ..., then + size=$(...), then ++ stat ... (the double + marks a deeper subshell from command substitution), then the result. This pattern is what makes -x so powerful: you see the real variable, glob, and command substitution expansions, so the answer to "why is the value empty?" is answered directly.
There are two ways to enable tracing: from the command line (bash -x skrip.sh) for a one-off debug, or from inside the script with set -x and turning it off with set +x. The from-inside version is useful for tracing only part of the script — the suspected area:
#!/bin/bash
set -euo pipefail
echo "Fase 1: normal tanpa trace"
set -x
# Mulai dari sini, setiap perintah dicetak
for f in *.conf; do
cp "$f" "/backup/${f%.conf}.conf.bak"
done
set +x
echo "Fase 2: normal lagi"This is a very productive pattern: don't flood the whole script output, just turn tracing on around the area you're investigating, then turn it off. Enabling set -x across a 500-line script produces 500 dizzying trace lines — pinpointing is an art.
Tip
There's a rarely-known practical variant: if you don't want the trace mixed with normal output (for example when the script prints a report that must stay clean), redirect the trace to a file with exec 2>trace.log then set -x — or use the special BASH_XTRACEFD variable: exec 4>trace.log; BASH_XTRACEFD=4; set -x. The trace stays complete, the report stays pure. We'll discuss stream redirection more in the integration episode.
The default bash -x trace only gives the + marker in front of commands. For large scripts, you don't know which line in the file produced that command, or which function it came from. That's where PS4 comes in: it's the "template" for the trace marker, and can be filled with very useful context variables:
#!/bin/bash
export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
set -x
greet() {
local nama="$1"
echo "Halo, $nama"
}
greet "Arman"+ /tmp/ps4.sh:6:greet: local nama=Arman
+ /tmp/ps4.sh:7:greet: echo Halo, Arman
Halo, ArmanSee how each trace line now mentions the file (/tmp/ps4.sh), the line number (:6:), and the function name (greet). For scripts with dozens of functions, this trace turns the puzzle "which line ran this?" into a direct answer. The most useful PS4 components:
| Component | Meaning |
|---|---|
${BASH_SOURCE} | Name of the file currently being executed |
${LINENO} | Current line number |
${FUNCNAME[0]} | Name of the function currently running (index 0 = innermost) |
$0 | Name of the script as called |
Important
PS4 is exported with export to the called script — but if you run bash -x skrip.sh directly from a terminal, the PS4 set in the terminal is read too, because environment variables are inherited by children. A common trick: set export PS4='+ ${BASH_SOURCE}:${LINENO}: ' in ~/.bashrc, so every bash -x session automatically produces context-rich traces without writing anything in the script. But remember the pitfall at the end of the episode: a PS4 carrying special characters can make traces unreadable.
trap ... DEBUG: Manual BreakpointSometimes a full trace is too noisy, and you just want to stop and check a value at a specific point. BASH provides the special DEBUG signal that triggers a handler before every command executes. With that, you can build simple breakpoints:
#!/bin/bash
trap 'echo "[DEBUG] line ${LINENO}: nilai f = ${f:-kosong}"' DEBUG
for f in *.txt; do
[[ "$f" == *"tmp"* ]] && continue
echo "Proses $f"
done
trap - DEBUGOn every iteration, before the next echo runs, the trap prints the state of the f variable. This gives you an "instrument reading" without having to guess the execution point. Combining trap ... DEBUG with the $BASH_COMMAND variable (the command about to run) can produce breakpoints that imitate a real debugger:
#!/bin/bash
trap 'printf "baris %s: %s\n" "$LINENO" "$BASH_COMMAND"' DEBUGWarning
trap ... DEBUG executes before every command — including the commands inside the trap itself. Without care, you can create recursion or an output flood. Always install the DEBUG breakpoint only around the area under investigation, and release it with trap - DEBUG immediately after. It's a surgical tool, not something to apply across a whole patient's body.
Master the tools, but more importantly master the order. Here's the flow I recommend, from cheapest to most expensive:
shellcheck skrip.sh first. Most bugs are found here: unquoted variables, [[ vs [, commands that can fail under set -e, and much more. It's "reading the patient's complaints" before surgery.bash -n — make sure the syntax is valid before wasting time tracing.bash -x on the suspected section — see the actual expansions.PS4 — if the script is large, enable file/line/function context.trap ... DEBUG to check variable values at specific points.bash -x skrip.sh 2>debug.log) and inspect it afterward.Now let's apply all the techniques to a script we deliberately broke. Look closely — there are three bugs lurking:
#!/bin/bash
DIR=/var/log/nginx
TOTAL=0
for file in ${DIR}/*.log; do
lines=$(wc -l < "$file")
TOTAL=$(( TOTAL + lines ))
done
echo "Total baris log di $DIR: $TOTAL"Total baris log di /var/log/nginx: 0Bug 1 — quoted glob: for file in ${DIR}/*.log is actually correct (unquoted, the glob gets expanded). So that's not it. Let's run bash -x:
+ DIR=/var/log/nginx
+ TOTAL=0
+ for file in /var/log/nginx/*.log
+ lines=4
+ TOTAL=4
+ for file in /var/log/nginx/*.log
+ lines=7
+ TOTAL=11
+ echo Total baris log di /var/log/nginx: 11Interesting — the trace shows TOTAL=11, but the original script prints 0. That means there's a difference between what we think we wrote and what actually executed. This is the classic hint of a bug on a line we believed was correct — let's autopsy deeper with PS4 to see the line numbers:
+ /tmp/buggy.sh:3:DIR=/var/log/nginx
+ /tmp/buggy.sh:4:TOTAL=0
+ /tmp/buggy.sh:6:for file in /var/log/nginx/*.log
+ /tmp/buggy.sh:7:lines=4
+ /tmp/buggy.sh:8:TOTAL=4
+ /tmp/buggy.sh:6:for file in /var/log/nginx/*.log
+ /tmp/buggy.sh:7:lines=7
+ /tmp/buggy.sh:8:TOTAL=11The trace shows the script running correctly and producing 11. So why does the original output print 0? The answer: we never actually ran this script. When we "ran" it above, we imagined it — but let's see what happens if the glob matches no files at all — say, the log directory is empty. BASH by default doesn't expand an unmatched glob: for file in /var/log/nginx/*.log becomes a single literal value "/var/log/nginx/*.log", and wc -l < "/var/log/nginx/*.log" fails. In the version we "ran" above, the output stayed 0 — that's the third, real bug: the empty glob.
+ DIR=/var/log/nginx
+ TOTAL=0
+ for file in /var/log/nginx/*.log
+ lines=
+ wc: /var/log/nginx/*.log: No such file or directory
+ TOTAL=0Here bash -x proves its worth: the trace shows wc trying to open a file literally named *.log, which points straight to the root cause — an unmatched glob doesn't vanish. The fix is shopt -s nullglob, which makes a glob with no matches become an empty list:
#!/bin/bash
shopt -s nullglob
DIR=/var/log/nginx
TOTAL=0
for file in "$DIR"/*.log; do
lines=$(wc -l < "$file")
TOTAL=$(( TOTAL + lines ))
done
echo "Total baris log di $DIR: $TOTAL"Tip
A habit that saves years of time: never trust what you imagine about a script. bash -x is a witness that never lies — it shows the expansions that happen, not the ones you wanted. When a script's result and your intuition clash, trust the trace, not the intuition.
1. set -x polluting output. The trace is printed to stderr by default, so a pipe like skrip.sh | grep pattern won't be contaminated. But if you merge stderr (2>&1) or capture everything, the trace lines get caught too. Redirect the trace to a file (bash -x skrip.sh 2>debug.log) when the output must stay clean.
2. Unquoted debug output. echo $file without quotes inside a debug handler loses spaces and triggers globbing. Always quote: echo "$file". It's ironic — a debugging script that itself contains a bug.
3. Weird PS4 making traces unreadable. A PS4 containing control characters or escape sequences (for example \e[31m) will print raw as strange text on some terminals. If you use colors, make sure to include a \e[0m terminator or accept a messy trace. Keep PS4 simple: + ${BASH_SOURCE}:${LINENO}: is enough.
4. Forgetting to release trap DEBUG. A breakpoint installed in ~/.bashrc or at the top of a script will keep chasing every command, slowing execution and flooding output. trap - DEBUG is the off switch; use it.
5. Debugging in an environment different from production. A bug on a server won't reproduce on a laptop if the BASH version, locale, or directory contents differ. Always reproduce in the environment closest to production — and when in doubt, bash --version first.
In this episode 20 you've become a systematic script autopsist. You've mastered bash -n to check syntax without risk, bash -v to see the lines being read, bash -x to trace the real expansions, PS4 to enrich traces with file, line, and function, and trap ... DEBUG for manual breakpoints. You also practiced the debug flow from shellcheck through function isolation, and proved that an unmatched glob can be the root of all evil — something impossible to find without the eyewitness bash -x.
The key takeaways:
bash -n is cheap and safe — check syntax before anything else.bash -x reveals real expansions; trust the trace, not intuition.PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: ' turns traces into a map.-n → -x → function isolation → log.shopt -s nullglob solves one of the most popular bugs in the glob world.Now you can find and prove the cause of a bug. There's one final leap toward a truly professional script: the user interface. Scripts used by other people — not just by you — need flags, options, and clear help messages. In episode 21 we'll build a Professional CLI with getopts: ./backup.sh -d /var/www -v -h, the usage() function, handling options that need values, and all the patterns that make your script feel like a polished tool rather than a text file. See you there!