Skip to content
Go back

Debugging Bash Scripts: set -x and Beyond

By SumGuy 4 min read
Debugging Bash Scripts: set -x and Beyond

set -x: The Lifesaver

When your bash script is behaving strangely, turn on tracing:

#!/bin/bash
set -x
echo "Starting..."
cd /tmp
ls -la

Output:

+ echo 'Starting...'
Starting...
+ cd /tmp
+ ls -la
total 128
drwxrwxrwt 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/bash
set -x
echo "Debugging on"
set +x
secret_password="hunter2" # Don't print this line
set -x
echo "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 /tmp
ls

Output:

[script.sh:5] echo 'Starting'
Starting
[script.sh:6] cd /tmp
[script.sh:7] ls

Now you see the filename and line number. Way more useful.

For complex scripts, add function names:

Terminal window
export PS4='[${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}] '
set -x
process_file() {
cat "$1"
}
process_file /tmp/data.txt

Output:

[script.sh:10:process_file] cat /tmp/data.txt

You 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 -x
fi
# ... rest of script

Or trace only a function:

#!/bin/bash
buggy_function() {
(
set -x
echo "Inside function"
some_command
another_command
)
}
buggy_function

The subshell () keeps the set -x local. Outside the function, tracing is off.

Combine with Strict Mode

Pair tracing with error checking:

#!/bin/bash
set -euo pipefail
set -x
# Now every error shows up with line numbers and context
cd /nonexistent 2>&1 | head -5

When 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/bash
set -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_variable

Output:

DEBUG: Line 4, Command: echo "Line 1"
Line 1
DEBUG: Line 5, Command: echo "Line 2"
Line 2
DEBUG: Line 6, Command: some_undefined_variable
bash: some_undefined_variable: command not found

The DEBUG trap fires before every command. Super useful for tracing execution flow.

For production scripts, combine trap and logging:

#!/bin/bash
set -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>&1
echo "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 /nonexistent

Output:

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 ;;
esac
done
run() {
if (( dry_run )); then
echo "[DRY-RUN] $@"
else
"$@"
fi
}
run rm -f /tmp/file.txt
run mkdir -p /tmp/newdir

Usage:

Terminal window
./script.sh # Actually runs commands
./script.sh -n # Shows what would run, doesn't execute

Real Example: Debugging a Cron Job

Your cron job isn’t working. Add logging and tracing:

#!/bin/bash
set -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>&1

Now when the job runs, all output goes to a log file. You see what commands ran and which one failed.

Check it:

Terminal window
tail -f /var/log/backup.log

The 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
}
outer

Output:

=== Stack Trace ===
15 inner script.sh
16 outer script.sh
17 <stdin> script.sh
====================

Shows the call stack when something breaks.

Bottom Line

Most scripts don’t need all this. But when something’s broken at 2 AM, these tools get you answers fast.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
Home Assistant + Node-RED: Automate Your Home Without Losing Your Mind
Next Post
Home Lab Hardware Guide 2026: What to Buy, What to Avoid, and What to Beg For

Related Posts