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 -xandset -x/set +x - Customise the trace prefix with
PS4to include the file name and the line - Send the trace to a separate file with
BASH_XTRACEFD - React to errors with
trap ERRand inspect each instruction withtrap DEBUG - Statically analyse a script with
shellcheck - Identify the classic traps:
(( ))withset -e, SIGPIPE,set -uand 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 -xtrace shows where it drifts - a script has a syntax error after a change,
bash -nidentifies it without running the code - a deployment script carries on after a failing command without any visible sign,
trap ERRcatches the break - a colleague hands you an undocumented script to fix,
shellchecklists 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/bashif truethen echo "ok"# missing fi
$ bash -n test.shtest.sh: line 6: syntax error: unexpected end of file# Syntactically valid script$ bash -n my_script.sh && echo "Syntax OK"Syntax OKUse 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 +.
$ bash -x my_script.sh+ a=42+ b=84+ echo 'Result: 84'Result: 84Each 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/bashset -euo pipefail
# Code without trace...prepare_env
# Turn on the trace only for the critical sectionset -xresult=$(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:
f() { echo "in f"; }fx=$(echo sub)( echo "sub-shell" )y=$(echo $(echo double))+ f+ echo 'in f'in f++ echo sub+ x=sub+ echo sub-shellsub-shell+++ echo double++ echo double+ y=doubleThe 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:
export PS4='[${BASH_SOURCE##*/}:${LINENO}] 'set -xmsg="hello"echo "$msg"set +xOutput:
[demo-debug.sh:35] msg=hello[demo-debug.sh:36] echo hellohello[demo-debug.sh:37] set +xEach 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.logexec {BASH_XTRACEFD}>debug.log
set -xcalc=$((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 +xThe 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/bashset -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_failtrap 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 instructiontrap 'echo " → line $LINENO"' DEBUG
x=1y=2z=$((x + y))
trap - DEBUG # turn offecho "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:
$ shellcheck my_script.shHere are four defects it detects, each as a to avoid / correct pair. The lines marked # to avoid are deliberate counter-examples:
# SC2086: unquoted variable, word splitting and globbingrm $file # to avoidrm "$file" # correct
# SC2046: unquoted $() resultcp $(ls *.log) /backup/ # to avoidcp ./*.log /backup/ # correct, or through an array
# SC2068: array expanded without double quotescmd ${array[@]} # to avoidcmd "${array[@]}" # correct
# SC2155: declaration and assignment combined with locallocal val=$(command) # to avoid: masks the exit codelocal val; val=$(command) # correctWatch 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:
set -en=0(( n++ )) # result = 0, exit code 1, set -e stops the script!echo "n=$n" # never runThe solution is an assignment, whose exit code is always 0, whatever the computed value:
set -en=0n=$((n + 1)) # exit code 0 whatever the valueecho "n=$n" # n=1Arithmetic 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.
set -ef() { (( a == 0 )) && echo "zero"; } # returns 1 when a is 1a=1f # the script stops here, code 1echo "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.
set -eo pipefailfor i in {1..100}; do echo "$i"; done | head -5 # carries on: the upstream already finishedseq 1 1000000 | head -5 # the script stops, code 141echo "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:
set -eo pipefailseq 1 1000000 | head -5 || true # code 141 expected, neutralised here onlyEmpty variables with set -u
set -u stops the script on any undefined variable, instead of replacing it with an empty string without warning:
set -uecho "$undefined" # bash: undefined: unbound variable, exit 1For a truly optional variable, provide a default value: the ${VAR:-default} expansion does not trigger the error:
set -uname="${USER_NAME:-anonymous}" # OK even when USER_NAME is not setecho "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:
directory="/path/with spaces"
# Problem: word splitting + globbingcp $directory/*.log /backup/
# Correct: double quotes + explicit globcp "$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.
| Symptom | Recommended tool | Action |
|---|---|---|
| Syntax error at launch | bash -n | Identify the exact line |
| Unexpected result | bash -x | Read the expansions after + |
| Error in a specific section | set -x / set +x | Enclose the section |
| Several sourced files | PS4 with BASH_SOURCE:LINENO | Locate the exact line |
| Script in CI / without a terminal | BASH_XTRACEFD | Separate trace and output |
| Style issues and known bugs | shellcheck | Full static analysis |
| Script stops without a message | trap ERR | Print line + exit code |
Key points
bash -nchecks the syntax without running, to use systematically before a deployment.bash -xtraces each command after expansion: it is the first reflex when facing an unexpected result.set -x/set +xfocus 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 $?' ERRcatches every unhandled error.shellcheckdetects common problems before running, plug it into your workflow.- With
set -e, avoid(( n++ ))whenn=0, usen=$((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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Customise Bash: .bashrc, prompt, bash-it and fzf
A new full quiz with random questions
Work again only on the questions you missed