Skip to content
DevOps devops shell 6 min read

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 pipefail to 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

ToolWhat it checksWhen to use it
set -euo pipefailStops on errors and undefined variablesAlways, at the top of every script
bash -n script.shSyntax errors only, no executionBefore running anything risky
bash -x script.shRuntime trace of real valuesWhen a value is wrong and you cannot see why
shellcheck script.shQuoting, logic, and style bugsOn 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 pipefail at the top of every script so failures surface early and loudly.
  • Run shellcheck on 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 -xset +x so the output stays readable.
  • Use bash -n as a quick safety gate before running scripts that delete, move, or overwrite files.
  • Look up each SCxxxx code on the ShellCheck wiki to learn why something is a problem, not just how to silence it.
  • Add shellcheck to your CI pipeline (continuous integration, the automated checks that run on every push) so broken scripts never merge.
Last updated June 15, 2026
Was this helpful?