set -x: The Lifesaver
When your bash script is behaving strangely, turn on tracing:
#!/bin/bashset -x
echo "Starting..."cd /tmpls -laOutput:
+ echo 'Starting...'Starting...+ cd /tmp+ ls -latotal 128drwxrwxrwt 14 root root 4096 Apr 4 10:30 .drwxr-xr-x 18 root root 4096 Apr 4 09:00 ..The + lines show what’s running. You see every command before it executes.
Turn it off selectively:
#!/bin/bashset -x
echo "Debugging on"
set +xsecret_password="hunter2" # Don't print this line
set -xecho "Debugging resumed"set +x disables tracing. set -x re-enables it.
PS4: Context for Debugging
By default, every traced line is prefixed with +. Add context:
#!/bin/bash
export PS4='[${BASH_SOURCE}:${LINENO}] 'set -x
echo "Starting"cd /tmplsOutput:
[script.sh:5] echo 'Starting'Starting[script.sh:6] cd /tmp[script.sh:7] lsNow you see the filename and line number. Way more useful.
For complex scripts, add function names:
export PS4='[${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}] 'set -x
process_file() { cat "$1"}
process_file /tmp/data.txtOutput:
[script.sh:10:process_file] cat /tmp/data.txtYou know exactly where in your script the code is running.
Trace Just Part of Your Script
Don’t want to trace the whole thing? Enable it on-demand:
#!/bin/bash
read -p "Enable debug mode? (y/n) " debug
if [[ "$debug" == "y" ]]; then set -xfi
# ... rest of scriptOr trace only a function:
#!/bin/bash
buggy_function() { ( set -x echo "Inside function" some_command another_command )}
buggy_functionThe subshell () keeps the set -x local. Outside the function, tracing is off.
Combine with Strict Mode
Pair tracing with error checking:
#!/bin/bashset -euo pipefailset -x
# Now every error shows up with line numbers and contextcd /nonexistent 2>&1 | head -5When the script fails, you see exactly which line broke.
trap for Cleanup and Debugging
Use trap to run code before exit (even on error):
#!/bin/bashset -e
cleanup() { echo "Cleaning up..." rm -f /tmp/tempfile}
trap cleanup EXIT
echo "Working..."some_command_that_fails
echo "This never runs if some_command fails"Even if some_command_that_fails exits the script, cleanup runs.
Use trap to debug:
#!/bin/bash
trap 'echo "DEBUG: Line $LINENO, Command: $BASH_COMMAND"' DEBUG
echo "Line 1"echo "Line 2"some_undefined_variableOutput:
DEBUG: Line 4, Command: echo "Line 1"Line 1DEBUG: Line 5, Command: echo "Line 2"Line 2DEBUG: Line 6, Command: some_undefined_variablebash: some_undefined_variable: command not foundThe DEBUG trap fires before every command. Super useful for tracing execution flow.
For production scripts, combine trap and logging:
#!/bin/bashset -e
logfile="/var/log/myscript.log"
trap 'echo "[ERROR] Script failed at line $LINENO" >> "$logfile"' ERR
echo "Starting backup..." >> "$logfile"rsync -av /data /backup >> "$logfile" 2>&1echo "Backup complete" >> "$logfile"BASH_COMMAND
Inside a trap, $BASH_COMMAND is the command that triggered it:
#!/bin/bash
trap 'echo "Failed: $BASH_COMMAND (line $LINENO)"' ERR
cd /nonexistentOutput:
Failed: cd /nonexistent (line 3)Lets you log exactly what broke.
Dry-Run Mode
Add a dry-run flag so users can see what would happen:
#!/bin/bash
dry_run=0
while getopts "n" opt; do case $opt in n) dry_run=1 ;; esacdone
run() { if (( dry_run )); then echo "[DRY-RUN] $@" else "$@" fi}
run rm -f /tmp/file.txtrun mkdir -p /tmp/newdirUsage:
./script.sh # Actually runs commands./script.sh -n # Shows what would run, doesn't executeReal Example: Debugging a Cron Job
Your cron job isn’t working. Add logging and tracing:
#!/bin/bashset -euo pipefail
logfile="/var/log/backup.log"
{ echo "=== $(date) ===" set -x
rsync -av /data /backup find /backup -type f -name "*.old" -delete
set +x echo "Backup completed successfully"} >> "$logfile" 2>&1Now when the job runs, all output goes to a log file. You see what commands ran and which one failed.
Check it:
tail -f /var/log/backup.logThe Stack Trace Function
Here’s a reusable function to show where errors happen:
#!/bin/bash
show_trace() { local frame=0 echo "=== Stack Trace ===" while caller $frame; do ((frame++)) done echo "=================="}
trap 'show_trace' ERR
inner() { false # Trigger error}
outer() { inner}
outerOutput:
=== Stack Trace ===15 inner script.sh16 outer script.sh17 <stdin> script.sh====================Shows the call stack when something breaks.
Bottom Line
set -xshows every command. Pair withPS4for context.trap ERRcatches failures and lets you log/cleanup.trap DEBUGtraces every command (expensive but thorough).- Combine with
set -euo pipefailfor fast failure detection.
Most scripts don’t need all this. But when something’s broken at 2 AM, these tools get you answers fast.