Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Debug a Bash script: bash -x, set -x, shellcheck

By updated

Improve this page

Debugging a Bash script always starts with the same question: is it a syntax, logic or environment problem? Bash offers several levels of diagnosis, from syntax validation (bash -n) to the line-by-line trace (bash -x), through static analysis (shellcheck) and reactive traps (trap ERR). Choosing the right tool for the right level of error saves a considerable amount of time. The outputs on this page were measured on Bash 5.2.37 and shellcheck 0.10 (Debian 13).

What you will learn

  • Check the syntax of a script without running it with bash -n
  • Turn on a line-by-line execution trace with bash -x and set -x/set +x
  • Customise the trace prefix with PS4 to include the file name and the line
  • Send the trace to a separate file with BASH_XTRACEFD
  • React to errors with trap ERR and inspect each instruction with trap DEBUG
  • Statically analyse a script with shellcheck
  • Identify the classic traps: (( )) with set -e, SIGPIPE, set -u and empty variables

When is it useful?

Bash debugging comes into play in several frequent situations:

  • a script that works locally fails silently in production, the bash -x trace shows where it drifts
  • a script has a syntax error after a change, bash -n identifies it without running the code
  • a deployment script carries on after a failing command without any visible sign, trap ERR catches the break
  • a colleague hands you an undocumented script to fix, shellcheck lists the problems automatically
  • a script written in a hurry must be fixed fast, for example under the time pressure of a practical exam

bash -n, check the syntax without running

bash -n reads the script, checks the syntax and stops. No command is run, and the exit code is 2 on error (measured), which makes it usable in an automated check.

# Script with a syntax error (missing fi)
$ cat test.sh
#!/bin/bash
if true
then
echo "ok"
# missing fi
$ bash -n test.sh
test.sh: line 6: syntax error: unexpected end of file
Terminal window
# Syntactically valid script
$ bash -n my_script.sh && echo "Syntax OK"
Syntax OK

Use bash -n before any run on a production server: it is free and risk-free. On the other hand it only sees the syntax, not a misnamed command or an empty variable.

bash -x, full execution trace

bash -x runs the script and prints each command after expansion of the variables, just before running it. The trace goes to the error output, prefixed with +.

Terminal window
$ bash -x my_script.sh
+ a=42
+ b=84
+ echo 'Result: 84'
Result: 84

Each line prefixed with + is a command actually run, variables already substituted. It is the first thing to do when a script produces an unexpected result.

Turn on the trace for a portion of the script

set -x turns the trace on from a given point, set +x turns it off: only the enclosed section shows in the output:

#!/bin/bash
set -euo pipefail
# Code without trace...
prepare_env
# Turn on the trace only for the critical section
set -x
result=$(complex_processing "$1")
set +x
echo "Result: $result"

The number of + shows the substitution level: each nested $( ) adds one. A function call does not add any, nor does a sub-shell ( ) written explicitly. Measured on this five-line script:

Terminal window
f() { echo "in f"; }
f
x=$(echo sub)
( echo "sub-shell" )
y=$(echo $(echo double))
+ f
+ echo 'in f'
in f
++ echo sub
+ x=sub
+ echo sub-shell
sub-shell
+++ echo double
++ echo double
+ y=double

The function's echo stays at a single +, the one inside the substitution goes to ++, and the nested substitution to +++.

PS4, customise the trace prefix

By default, PS4='+ '. You can enrich it with the file name and the line number, thanks to the BASH_SOURCE and LINENO variables evaluated on each line of trace:

Terminal window
export PS4='[${BASH_SOURCE##*/}:${LINENO}] '
set -x
msg="hello"
echo "$msg"
set +x

Output:

[demo-debug.sh:35] msg=hello
[demo-debug.sh:36] echo hello
hello
[demo-debug.sh:37] set +x

Each trace line now shows the file and the line number, essential when functions coming from several sourced files are involved.

BASH_XTRACEFD, trace to a separate file

When the trace mixes with the script's output, readability suffers. BASH_XTRACEFD names the file descriptor that receives the trace: exec {BASH_XTRACEFD}>debug.log opens one, and stores its number in the variable itself:

#!/bin/bash
# Send the trace to debug.log
exec {BASH_XTRACEFD}>debug.log
set -x
calc=$((6 * 7))
result=$(date '+%Y%m%d')
set +x
exec {BASH_XTRACEFD}>&- # close the descriptor
echo "calc=$calc, date=$result"
# The standard output is clean; debug.log holds the trace
$ cat debug.log
+ calc=42
++ date +%Y%m%d
+ result=20260929
+ set +x

The full trace is indeed in debug.log, including the date substitution, and the standard output stays clean. This technique is particularly useful for scripts run in cron or in CI, where the trace must not pollute the application logs.

trap, react to execution events

trap ERR, catch errors

trap 'command' ERR runs command every time a command ends with a non-zero code, with or without set -e (measured). With set -e, the handler runs just before exiting:

#!/bin/bash
set -euo pipefail
handle_error() {
echo "ERROR at line $1, exit code: $2" >&2
echo "Script aborted." >&2
}
trap 'handle_error $LINENO $?' ERR
command_that_may_fail

trap DEBUG, inspect each instruction

trap 'command' DEBUG runs command before each instruction, handy to count executions or follow the state of a variable:

#!/bin/bash
# Print the line number before each instruction
trap 'echo " → line $LINENO"' DEBUG
x=1
y=2
z=$((x + y))
trap - DEBUG # turn off
echo "z=$z"

In practice, trap DEBUG is mostly used to instrument a suspicious section, not to trace everything: on a whole script, the output soon becomes unreadable.

shellcheck, static analysis

shellcheck analyses a script without running it and reports errors, dangerous practices and POSIX incompatibilities. It installs with apt install shellcheck on Debian and Ubuntu, from EPEL (package ShellCheck) on RHEL:

Terminal window
$ shellcheck my_script.sh

Here are four defects it detects, each as a to avoid / correct pair. The lines marked # to avoid are deliberate counter-examples:

Terminal window
# SC2086: unquoted variable, word splitting and globbing
rm $file # to avoid
rm "$file" # correct
# SC2046: unquoted $() result
cp $(ls *.log) /backup/ # to avoid
cp ./*.log /backup/ # correct, or through an array
# SC2068: array expanded without double quotes
cmd ${array[@]} # to avoid
cmd "${array[@]}" # correct
# SC2155: declaration and assignment combined with local
local val=$(command) # to avoid: masks the exit code
local val; val=$(command) # correct

Watch out for a frequent variant of the third case: $array[@], without braces, is not a badly quoted array but another mistake. shellcheck classifies it as SC1087 (missing braces) and SC2128: Bash only expands the first item, followed by the text [@].

Classic traps

(( )) with set -e

(( n++ )) returns exit code 1 when the result of the expression is 0, which triggers set -e. With n=0, the post-increment is worth precisely 0:

Terminal window
set -e
n=0
(( n++ )) # result = 0, exit code 1, set -e stops the script!
echo "n=$n" # never run

The solution is an assignment, whose exit code is always 0, whatever the computed value:

Terminal window
set -e
n=0
n=$((n + 1)) # exit code 0 whatever the value
echo "n=$n" # n=1

Arithmetic tests call for more nuance. In the middle of a script, (( a == 0 )) && echo "zero" does not stop the script when a is 1: a command tested by && is exempted from set -e (measured). The trap fires when that line is the last one of a function: the function then returns 1, and its call, in turn, stops the script.

Terminal window
set -e
f() { (( a == 0 )) && echo "zero"; } # returns 1 when a is 1
a=1
f # the script stops here, code 1
echo "never shown"

The if (( a == 0 )); then echo "zero"; fi form avoids the problem: an if whose condition is false returns 0, and so does the function.

SIGPIPE with set -e and pipes

When head has read what it wanted, it closes the pipe; the upstream command that is still writing receives SIGPIPE and exits with code 141. Two conditions must be met for the script to stop, and the measurement shows it: pipefail must be on (without it, only head's code counts), and the upstream must still be writing when head closes. A hundred lines fit in the pipe buffer and trigger nothing; a million do.

Terminal window
set -eo pipefail
for i in {1..100}; do echo "$i"; done | head -5 # carries on: the upstream already finished
seq 1 1000000 | head -5 # the script stops, code 141
echo "never shown"

The defect is intermittent by nature: it depends on the volume and on the speed of both sides of the pipe. When the truncation is intended, explicitly neutralise the code of that single pipeline:

Terminal window
set -eo pipefail
seq 1 1000000 | head -5 || true # code 141 expected, neutralised here only

Empty variables with set -u

set -u stops the script on any undefined variable, instead of replacing it with an empty string without warning:

Terminal window
set -u
echo "$undefined" # bash: undefined: unbound variable, exit 1

For a truly optional variable, provide a default value: the ${VAR:-default} expansion does not trigger the error:

Terminal window
set -u
name="${USER_NAME:-anonymous}" # OK even when USER_NAME is not set
echo "Hello $name"

Quotes and globbing

A path containing a space breaks a command as soon as the variable is not quoted. The fix is to quote the variable and leave the glob outside the quotes:

Terminal window
directory="/path/with spaces"
# Problem: word splitting + globbing
cp $directory/*.log /backup/
# Correct: double quotes + explicit glob
cp "$directory"/*.log /backup/

Debugging strategy by type of error

The right tool depends on the symptom. This table sums up the page: start from what you observe, and apply the tool on the same line before moving on to the next.

SymptomRecommended toolAction
Syntax error at launchbash -nIdentify the exact line
Unexpected resultbash -xRead the expansions after +
Error in a specific sectionset -x / set +xEnclose the section
Several sourced filesPS4 with BASH_SOURCE:LINENOLocate the exact line
Script in CI / without a terminalBASH_XTRACEFDSeparate trace and output
Style issues and known bugsshellcheckFull static analysis
Script stops without a messagetrap ERRPrint line + exit code

Key points

  • bash -n checks the syntax without running, to use systematically before a deployment.
  • bash -x traces each command after expansion: it is the first reflex when facing an unexpected result.
  • set -x / set +x focus the trace on a specific section of the script.
  • PS4='[${BASH_SOURCE##*/}:${LINENO}] ' makes the trace usable in a multi-file script.
  • trap 'handler $LINENO $?' ERR catches every unhandled error.
  • shellcheck detects common problems before running, plug it into your workflow.
  • With set -e, avoid (( n++ )) when n=0, use n=$((n + 1)).

Knowledge check

Check that the essentials of this guide stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn