How to Debug a Bash Script (set -x, trap, and Common Mistakes)
Bash fails silently, swallows errors, and gives you no line numbers by default. Three built-in flags change all of that. Here is how to use them and what to look for.
The three modes every bash debugger uses
Bash ships three flags that together cover almost every debugging scenario. You can set them at the top of any script or pass them on the command line.
| Flag | Long form | What it does |
|---|---|---|
| set -x | set -o xtrace | Print every command before it runs, with arguments expanded |
| set -e | set -o errexit | Exit immediately when any command returns a non-zero status |
| set -u | set -o nounset | Treat unset variables as an error and exit |
set -x (xtrace) is the most immediately useful. Without it, a broken script exits with a cryptic message and no indication of what ran. With it, every expanded command prints to stderr before execution:
# Without set -x — script silently misbehaves
./deploy.sh
# Error: deployment failed
# With set -x — you see exactly what ran
bash -x ./deploy.sh
+ source /etc/profile.d/env.sh
+ export APP_ENV=production
+ rsync -av ./dist/ user@host:/var/www/
+ '[' -z '' ']'
+ echo 'Error: deployment failed'set -e (errexit) turns bash from a language that ignores errors into one that stops on them. By default, bash runs every line regardless of exit codes. set -e makes it behave like most other languages:
#!/usr/bin/env bash
set -e
cp /source/file /destination/ # if this fails, script stops here
rm /source/file # this line never runs if cp failed
echo "Done" # neither does thisset -u (nounset) catches the silent bug of referencing a variable you forgot to set. Without it, $MYVAR expands to an empty string. With it, the script exits with an error:
#!/usr/bin/env bash
set -u
echo "Deploying to: $DEPLOY_HOST"
# Without set -u: prints "Deploying to: " — silent empty string
# With set -u: bash: DEPLOY_HOST: unbound variable — exits immediatelyStart every non-trivial script with set -euo pipefail. The -o pipefail flag is covered in section 4 — it closes a gap that -e alone leaves open.
Reading xtrace output: PS4 and file+line numbers
By default, set -x prefixes each traced line with +. Nested calls get extra + characters (subshells get ++, sub-subshells get +++). That is useful for call depth, but it tells you nothing about which file or line produced the output.
The PS4 variable controls the trace prefix. Set it before enabling -x to get file and line information on every trace line:
export PS4='+(${BASH_SOURCE}:${LINENO}): '
set -x
# Now xtrace output looks like this:
+(deploy.sh:14): export APP_ENV=production
+(deploy.sh:15): rsync -av ./dist/ user@host:/var/www/
+(lib/utils.sh:8): log_message "rsync complete"The BASH_SOURCE array holds the name of the current script (even when sourcing other files), and LINENO is the current line number. Together they give you a stack trace you can actually use.
For even richer output, include the function name with ${FUNCNAME[0]}:
export PS4='+(${BASH_SOURCE}:${LINENO}) ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
set -x
# Output now includes function name when inside a function:
+(deploy.sh:22) run_checks(): checking host connectivity
+(deploy.sh:23) run_checks(): ping -c1 $DEPLOY_HOSTTo debug only a specific section of a script without flooding the entire output, toggle -x around the problem area:
#!/usr/bin/env bash
# ... lots of setup code ...
set -x # start tracing here
run_problematic_function
set +x # stop tracing here
# ... rest of script ...trap ERR for error capture
The trap command runs a handler whenever a signal or pseudo-signal fires. The ERR pseudo-signal fires whenever a command returns a non-zero exit code — which makes it ideal for catching errors even before set -e exits the script.
The simplest useful trap prints the line number of the failure:
#!/usr/bin/env bash
set -euo pipefail
trap 'echo "Error on line $LINENO"' ERR
cp /missing/file /destination/
# Output: Error on line 5A more complete trap captures the exit code, the line number, and the command that failed:
#!/usr/bin/env bash
set -euo pipefail
trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR
on_error() {
local exit_code=$1
local line_num=$2
local last_cmd=$3
echo "---"
echo "Script failed at line ${line_num}"
echo "Command: ${last_cmd}"
echo "Exit code: ${exit_code}"
echo "---"
}
# Any failing command now produces a structured error report$BASH_COMMAND holds the command currently being executed when the trap fires. It is one of the most useful variables you are probably not using.
You can also trap EXIT to run cleanup code whenever the script ends — whether from an error, a normal exit, or a signal:
#!/usr/bin/env bash
set -euo pipefail
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT # always cleans up, even on error
# ... use $TMPDIR freely ...
cp large_file "$TMPDIR/"
process "$TMPDIR/large_file"
# $TMPDIR is removed when the script exits for any reasonThe 5 most common bash mistakes caught by debugging
-
01
Unquoted variables
The single most common bash bug. When you write cp $SOURCE $DEST and either variable contains a space, bash performs word splitting and the command receives the wrong arguments. set -x shows you the expanded value immediately. Fix: always double-quote variable expansions: cp "$SOURCE" "$DEST".
# Broken — set -x reveals the split: SOURCE="my file.txt" cp $SOURCE /backup/ # + cp my file.txt /backup/ ← three arguments, not two # Fixed: cp "$SOURCE" /backup/ # + cp 'my file.txt' /backup/ ← correct -
02
Pipe error swallowing
In a pipeline like cmd1 | cmd2, bash sets the exit code to that of the last command only — cmd2. If cmd1 fails, set -e never sees it because the pipeline "succeeded" (from cmd2's perspective). This causes set -e to silently miss real failures.
# cmd1 fails, but the pipeline exit code is 0 (from grep) broken_command | grep "pattern" echo $? # prints 0 — looks fine, but broken_command failed -
03
Missing set -o pipefail
The fix for mistake #2. With pipefail, a pipeline's exit code is the rightmost non-zero exit code in the pipe, or zero if everything succeeded. Combined with set -e, failed pipe stages now cause the script to exit. Add it to your standard header: set -euo pipefail.
#!/usr/bin/env bash set -euo pipefail broken_command | grep "pattern" # Now the pipeline fails because broken_command returned non-zero # set -e sees the failure and exits the script -
04
Command-not-found silenced by ||
A pattern like command || true is often used to prevent set -e from exiting on an expected failure. The problem: it also silences genuine errors, including typos in command names. set -x will show you the command and its exit code — check that what you intended to run actually ran.
# Intent: ignore exit code if service is already stopped systemclt stop myservice || true # typo: systemclt not systemctl # Without -x: silently does nothing # With -x: + systemclt stop myservice # bash: systemclt: command not found # + true -
05
Integer vs string comparison
Bash uses different operators for integers and strings. Using == for numeric comparisons does a string comparison — "10" == "9" is false (correct), but it fails subtly with leading zeros or whitespace. Use -eq, -lt, -gt for numbers inside [[ ]] or arithmetic (( )).
# Wrong: string comparison — "10" sorts before "9" lexicographically if [[ "$count" == "9" ]]; then echo "exactly 9"; fi # Wrong in a different way: leading-zero issue version="09" if [[ "$version" == "9" ]]; then echo "match"; fi # false # Correct: arithmetic comparison if [[ "$count" -eq 9 ]]; then echo "exactly 9"; fi if (( count == 9 )); then echo "exactly 9"; fi
Bash Unfucked covers the patterns above and 25 more — real failures with exact commands to diagnose and fix them. No searching, no guessing under pressure.
Get Bash Unfucked — $2 →