OnCallReady

Lesson 6.22 · Bash Scripting · 13 min read

shellcheck, bash -n and bash -x

In plain words

Imagine three ways of checking an essay before handing it in. A grammar checker reads it and points at sentences that are probably wrong, like "you forgot to close this bracket" or "this word can be misread" (shellcheck). A quick skim only checks that every sentence ends properly, without judging whether it makes sense (bash -n). And reading it aloud to a friend shows exactly how each sentence sounds when spoken (bash -x).

shellcheck finds real bugs without running anything: unquoted variables (SC2086), local x=$(cmd) hiding failures (SC2155), cd without || exit (SC2164). bash -n only checks the syntax. bash -x prints every command after expansion as it runs, so you see what your script actually did, with a + in front of each line.

Why this lesson

By now you know a dozen ways a script can go wrong quietly: missing quotes, local x=$(cmd), an unguarded cd. Nobody remembers all of them while typing. So you let tools check for you, and you learn to watch a script run step by step when it surprises you.

What you need to know already: everything in this chapter so far; installing a package with apt (1.11).

Three tools, three questions

shellcheck script.sh    "is this correct?"   - static analysis, finds real bugs
bash -n script.sh       "does it parse?"     - syntax only, runs nothing
bash -x script.sh       "what did it DO?"    - trace every command as it runs

shellcheck codes you will see constantly

Every finding has a code, SC + four digits. You can look any of them up by code. The ones this chapter has already shown you:

SC2148  missing shebang
SC2086  unquoted variable - word splitting and globbing
SC2046  unquoted $(...) - same problem
SC2006  backticks; use $(...)
SC2155  local x=$(cmd) masks the exit code
SC2164  cd without || exit
SC2162  read without -r
SC2115  "rm -rf $dir/" - use "${dir:?}" so an empty var cannot become /
SC2002  useless cat
SC2181  checking $? instead of testing the command directly

(Backticks - a command between two backtick characters - are the old way to write $(cmd). A "useless cat" is cat file | grep x where grep x file would do.)

Every one is a real bug someone has shipped. Install it (sudo apt install shellcheck) and run it on every script before you trust it - it takes seconds and catches the class of mistake that only shows up with an unusual filename or an empty variable.

A false positive is a finding that is wrong for your case. Silence one deliberately and narrowly with a comment:

# shellcheck disable=SC2086

directly above the line, never at the top of the file.

bash -x is the debugger

$ bash -x deploy.sh -e prod
+ env=
+ tag=latest
+ getopts :e:t:nh opt
+ env=prod
+ echo 'deploying latest to prod'

Each + line is one command, after expansion, so you see what it actually became - which is usually where the surprise is (+ env= shows env started empty). The trace goes to stderr. Turn it on for part of a script with set -x ... set +x.

PS4 is the text printed at the start of each trace line (default + ). Adding the file name and line number makes long traces readable:

export PS4='+ ${BASH_SOURCE}:${LINENO}: '

(BASH_SOURCE is the script's file name, LINENO the current line number; the single quotes delay their expansion until each trace line is printed.)

bash -n

Reads the whole script without running anything. It is instant, and the only one of the three that is safe to run against a script you have not read.

It only catches syntax, not sense: rm -rf / passes bash -n happily.

What you can now do

Why it helps

shellcheck is the single most effective tool for bash quality: it catches the bugs this chapter covers automatically, in seconds, and it belongs in every repository, run before every commit (a git pre-commit hook can run it for you). Running it over a team's existing scripts typically surfaces dozens of latent bugs in deploy and setup scripts, many of them the "empty variable" or "space in a path" kind that only fail in production.

bash -x with a good PS4 is how you debug a failing timer job or service script in minutes: you see the exact expanded command that failed. bash -n is a free guard in pre-commit hooks. The ship.sh incident at the end of this chapter is exactly this workflow: find the bugs, fix them, prove it with shellcheck.

Commands in this lesson

echo bash

FAQ

Is it OK to disable shellcheck warnings?

Yes, when you have understood the warning and the code is intentionally that way, for example deliberate word splitting. Disable narrowly: a # shellcheck disable=SC2086 comment on the line directly above the affected command, ideally with a short reason. Avoid file-wide disables at the top, which silence future real bugs too. A .shellcheckrc can set project-wide options like the shell dialect.

Does bash -n catch runtime errors?

No. It parses the script without executing anything, so it only finds syntax errors: unclosed quotes, missing fi or done, bad case syntax. It will not notice misspelled commands, wrong variables, unquoted expansions or dangerous logic like rm -rf "$dir/" with an empty dir. It is cheap and safe, but only one layer; shellcheck and tests cover the rest.

How do I trace only part of a script?

Wrap the section in set -x and set +x. Tracing goes to stderr, so it does not corrupt stdout. To trace a whole run without editing, use bash -x script.sh. Setting PS4='+ ${BASH_SOURCE}:${LINENO}: ' adds file and line numbers to each trace line. Be careful with secrets: set -x prints expanded values, including tokens and passwords, into logs.

Does shellcheck work for sh scripts too?

Yes. It reads the shebang to decide the dialect (sh, bash, dash, ksh), and warns about bash-only features in #!/bin/sh scripts, which is valuable on systems where /bin/sh is dash. You can force it with -s sh or a # shellcheck shell=sh directive for files without a shebang, such as sourced libraries.

What is BASH_XTRACEFD?

A variable naming the file descriptor that set -x output is written to. By default the trace goes to stderr, mixed with real error messages. exec 5> trace.log; BASH_XTRACEFD=5; set -x sends the trace to a separate file, so logs stay readable and the trace can be kept for debugging a failure later. It needs bash 4.1 or later.

In an interview Junior

How do you debug a bash script that is not doing what you expect?

Three tools, three questions:

  1. shellcheck script.sh - "is this correct?" Static analysis: it reads the script without running it and points at known bugs, each with a code - SC2086 unquoted variable, SC2155 local x=$(cmd), SC2164 cd without || exit, SC2162 read without -r. It often finds the bug outright.
  2. bash -n script.sh - "does it parse?" Syntax only, runs nothing, so it is safe on a script you have not read.
  3. bash -x script.sh args - "what did it actually do?" Prints every command after expansion with a + in front, on stderr, so you see the empty variable or the split path. set -x ... set +x around one part; export PS4='+ ${BASH_SOURCE}:${LINENO}: ' adds file and line.

Silence a shellcheck false positive narrowly, with # shellcheck disable=SC2086 directly above the line.

Also asked: What does bash -n catch, and what does it miss? · Which shellcheck warnings do you see most, and what do they mean? · How do you trace only one part of a script?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.