Debugging Shell Scripts
Even simple shell scripts go wrong: a variable is empty when you expected a value, a loop runs zero times, or a filename with a space breaks everything. Debugging a script means watching exactly what it does, line by line, so you can see where reality differs from your plan. Bash ships with two built-in helpers (a trace mode and a syntax checker), and there is one external tool, shellcheck, that is so good it should be part of every script you write. This page shows all three.
Why scripts fail silently
A big reason shell bugs are hard is that Bash often keeps going after an error instead of stopping. If a command fails or a variable is empty, the next line still runs, sometimes with damaging results. So your first debugging tool is not a tool at all: add safety flags to the top of every script.
#!/usr/bin/env bash
set -euo pipefail
Here set -e means “exit the moment any command fails”, set -u means “treat an unset (undefined) variable as an error instead of an empty string”, and set -o pipefail means “if any command in a pipeline fails, the whole pipeline fails”. With these on, the script stops at the real point of failure instead of stumbling forward and confusing you.
Add
set -euo pipefailto every new script before you write any logic. It turns silent, mysterious failures into clear, early ones.
Trace mode with set -x
set -x (the “x” stands for “execute trace”) tells Bash to print every command it runs, with variables already expanded (filled in), just before running it. This is the single most useful debugging trick in shell scripting. You see the actual values, not the code you typed.
Create a small buggy script to try it:
nano /tmp/greet.sh
#!/usr/bin/env bash
set -euo pipefail
name="Ada"
greeting="Hello, $name"
files=$(ls /etc | head -3)
echo "$greeting. First files: $files"
Run it with tracing turned on for the whole script using the -x flag:
bash -x /tmp/greet.sh
Output:
+ name=Ada
+ greeting='Hello, Ada'
++ ls /etc
++ head -3
+ files='adduser.conf
alsa
alternatives'
+ echo 'Hello, Ada. First files: adduser.conf
alsa
alternatives'
Hello, Ada. First files: adduser.conf
alsa
alternatives
Each line that starts with + is the command Bash is about to run, with variables already substituted. Extra + signs (like ++) show nesting, such as commands inside a $(...) substitution. You can see name got the value Ada and greeting became the full string. When a value is wrong, this trace tells you exactly which line broke it.
Tracing only part of a script
You usually do not want to trace the whole script. Turn the trace on right before the suspect section and off right after it:
echo "normal output here"
set -x # tracing ON
risky_command --some flag
result=$(date +%s)
set +x # tracing OFF (note the + sign)
echo "back to normal"
set -x enables tracing and set +x disables it. The minus turns a setting on; the plus turns it off. This keeps the noise focused on the lines you actually care about.
Syntax checking with bash -n
bash -n (the “n” means “no execute”) reads the whole script and checks it for syntax errors without running a single command. This is perfect for catching a missing fi, done, or quote before you run something destructive like rm.
bash -n /tmp/greet.sh
If the syntax is fine, it prints nothing and exits successfully. Now introduce a bug by deleting the done from a loop, then check again:
Output:
/tmp/greet.sh: line 9: syntax error: unexpected end of file
Bash points you near the problem. Note that bash -n only finds grammar mistakes. It cannot find logic mistakes (like comparing the wrong variable) and it does not catch quoting problems that are technically valid syntax. For those, you need the next tool.
shellcheck: the linter every script needs
shellcheck is a “linter” (a tool that reads your code and warns about likely bugs and bad practices) built specifically for shell scripts. It catches an enormous range of problems that Bash itself will run without complaint: unquoted variables, misused comparisons, useless cat pipes, and many subtle traps. You should run it on every script you write.
Installing shellcheck on Ubuntu
sudo apt update
sudo apt install -y shellcheck
Confirm it installed:
shellcheck --version
Output:
ShellCheck - shell script analysis tool
version: 0.10.0
license: GNU General Public License, version 3
website: https://www.shellcheck.net
Catching a real quoting bug
Quoting bugs are the most common shell mistake. When you use a variable without double quotes around it, Bash splits it on spaces and expands wildcards, which breaks any value containing a space (like a filename). Bash runs this happily, but the result is wrong. Make a script that has this bug:
nano /tmp/backup.sh
#!/usr/bin/env bash
set -euo pipefail
src="/home/deploy/my report.txt"
dest=/tmp/backup
cp $src $dest
This looks fine and Bash will not complain about the syntax. But $src is unquoted, so Bash sees cp /home/deploy/my report.txt /tmp/backup as three arguments and tries to copy two separate files. Run shellcheck on it:
shellcheck /tmp/backup.sh
Output:
In /tmp/backup.sh line 7:
cp $src $dest
^--^ SC2086: Double quote to prevent globbing and word splitting.
Did you mean:
cp "$src" "$dest"
For more information:
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globb...
Every warning has a code like SC2086. You can paste that code into the ShellCheck wiki to read a full explanation. Here it tells you exactly what to fix and even shows the corrected line. Apply the fix:
cp "$src" "$dest"
Run shellcheck again and it prints nothing, meaning the script is clean.
Which tool for which job
| Tool | What it checks | When to use it |
|---|---|---|
set -euo pipefail | Stops on errors and undefined variables | Always, at the top of every script |
bash -n script.sh | Syntax errors only, no execution | Before running anything risky |
bash -x script.sh | Runtime trace of real values | When a value is wrong and you cannot see why |
shellcheck script.sh | Quoting, logic, and style bugs | On every script, ideally before each commit |
Use them together: write the script with set -euo pipefail, lint it with shellcheck, syntax-check with bash -n, and when behaviour is still wrong, trace it with bash -x.
Best Practices
- Put
set -euo pipefailat the top of every script so failures surface early and loudly. - Run
shellcheckon every script before you trust it; treat its warnings as bugs to fix, not noise to ignore. - Always double-quote your variables (
"$var") unless you have a specific reason not to. This prevents the most common class of shell bugs. - Trace narrowly: wrap only the suspect section in
set -x…set +xso the output stays readable. - Use
bash -nas a quick safety gate before running scripts that delete, move, or overwrite files. - Look up each
SCxxxxcode on the ShellCheck wiki to learn why something is a problem, not just how to silence it. - Add
shellcheckto your CI pipeline (continuous integration, the automated checks that run on every push) so broken scripts never merge.