The pre-commit hook in 08-04 invoked shellcheck -x without explaining what it was. This lesson explains it. ShellCheck is a program that reads your scripts without running them and tells you where the errors are: the unquoted variable from 03-06, the cd without || from 05-01, the misused $? from 03-04, the local x=$(cmd) that swallows the exit code from 04-02. In other words, almost everything this course has taught you to avoid, plus a hundred cases we have not seen. And alongside it, shfmt, the formatter that closes what 08-01 left pending: making style stop being a team argument and become a command.

Contents

  1. What static analysis is and why it is essential in Bash
  2. Installing and running ShellCheck
  3. Options that change the result
  4. Anatomy of a warning
  5. The warnings you will see most
  6. Summary table of codes
  7. Silencing with judgment
  8. shfmt: formatting stops being a matter of opinion
  9. Integration: editor, hook and CI
  10. Other tools
  11. Application: running ShellCheck over the whole toolkit

  1. What static analysis is and why it is essential in Bash

Static analysis means examining the code without running it, looking for patterns that almost always indicate a defect. It exists for every language, but in Bash it is especially valuable for one concrete reason: the interpreter warns you about almost nothing until it is too late.

directory="/srv/veloz/july data"
rm -rf $directory/*.tmp

Bash runs that without a single complaint. There is no compiler, no type checking, no warning: it simply deletes in two different paths, neither of which is the one you meant. And the worst part is that it works perfectly for months as long as no directory has spaces. Bash bugs do not fail early, they wait.

Method When it catches the defect Cost
Run it and see what happens When the conditions occur (perhaps in production, at dawn) High
Review by a colleague If they notice; missing quotes are very easy to overlook Medium
Tests with Bats (08-06) If the case is covered by a test Medium
ShellCheck Before you save the file Almost zero

  1. Installing and running ShellCheck

$ sudo apt install shellcheck          # Debian/Ubuntu; brew install on macOS
$ shellcheck bin/*.sh lib/common.sh

There is also a container image (koalaman/shellcheck) and a static binary, useful when you cannot install packages on the server. What makes it automatable is its exit code: 0 if there are no warnings, 1 if there are. That is why the pre-commit hook from 08-04 could just call it and check the result.

  1. Options that change the result

Option What it does When to use it
-s bash Forces the dialect Files with no shebang, or libraries that get sourced
-s sh Analyzes as POSIX sh Checking portability (08-07)
-S warning Only that severity or higher Starting on a legacy script with 200 warnings
-x Follows the source of other files Almost always, with the toolkit
-P dir Path to look for the sourced files in When the source uses a variable
-f gcc / -f json Line-based / structured output Editors, CI, grep, reports
-e SC2086 Excludes that code for the whole run Team conventions

The -x option deserves an explanation, because without it the toolkit produces false warnings. bin/daily-report.sh does source "$BASE_DIR/lib/common.sh" (05-06); without -x, ShellCheck does not read that library, does not know that veloz_log_info exists nor that VELOZ_DATA_DIR is defined, and it emits SC1091 and SC2154. With -x it follows the source and analyzes the whole thing:

$ shellcheck -x -P lib bin/daily-report.sh        # tty format: colors and context

In bin/daily-report.sh line 42:
    if [ $usage -gt $THRESHOLD ]; then
         ^----^ SC2086 (info): Double quote to prevent globbing and word splitting.

$ shellcheck -f gcc bin/daily-report.sh           # one line per warning
bin/daily-report.sh:42:10: note: Double quote to prevent globbing... [SC2086]

The tty format is for reading; gcc is the one editors, grep and CI systems consume.

  1. Anatomy of a warning

In bin/backup.sh line 87:
    cd $TARGET
    ^--------^ SC2164 (warning): Use 'cd ... || exit' in case cd fails.
       ^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.

Four elements: file and line with the exact fragment underlined — a single line can accumulate several warnings; the SCxxxx code, a stable identifier that is what you search for and what you silence; the severity (error, warning, info, style); and the message, short and actionable.

And one thing worth internalizing: every code has its own wiki page at shellcheck.net/wiki/SC2086, with the problem, correct and incorrect examples and the legitimate exceptions. When you do not understand a warning, that page is the answer; reading it is how you really learn Bash after a course.

  1. The warnings you will see most

SC2086 — Double quote to prevent globbing and word splitting. By far the most frequent, and the problem from 03-06: rm $file breaks on spaces and expands wildcards. Fix: rm "$file".

SC2046 — Quote this to prevent word splitting. The same problem over a command substitution: chmod 600 $(find . -name '*.conf'). The fix is not to quote it — that would pass the whole list as a single argument — but find ... -print0 | xargs -0 chmod 600 (05-01).

SC2006 — Use $(...) instead of legacy backticks. Backticks do not nest well and escape in strange ways; the fix is mechanical.

SC2164 — Use cd ... || exit. If the cd fails, the script carries on in the previous directory and the destructive commands that follow act where they should not. Fix: cd "$dir" || veloz_die 1 "cannot enter $dir".

SC2181 — Check exit code directly with if cmd;, not indirectly with $?. This is 03-04: if grep -q ERROR "$log"; then instead of checking $? on the next line. Besides being clearer, it avoids the classic mistake of slipping in a debug echo that changes $?.

SC2148 — add a shebang. The #!/usr/bin/env bash from 03-01 is missing. In libraries that are only sourced a shebang is not appropriate, and there the solution is -s bash or the # shellcheck shell=bash directive.

SC2034 — variable appears unused. Almost always a dead variable left over from a refactor; sometimes a false positive, when another file consumes it via source or a local -n.

SC2155 — Declare and assign separately to avoid masking return values. Subtle and very important (04-02):

local day=$(date -d "$input" +%F)    # the exit code is "local"'s: ALWAYS 0
local day                            # GOOD: two steps
day=$(date -d "$input" +%F) || return 1

With set -e this matters a lot: the first version does not abort even if date fails.

SC2016 — Expressions don't expand in single quotes. It is usually a false positive in awk '{print $3}', where single quotes are exactly what you want; it is silenced with a directive. SC2154 (variable referenced but not assigned) and SC1090/SC1091 (source not followable) are typical of libraries and are solved with -x -P lib or with # shellcheck source=lib/common.sh right above the source, which is preferable because it stays documented in the file itself.

  1. Summary table of codes

Code Problem Fix Lesson
SC2086 Unquoted expansion "$var" 03-06
SC2046 Unquoted $(...) in arguments -print0 + xargs -0 05-01
SC2006 Backticks $(...) 03-06
SC2164 cd with no error handling cd "$d" || veloz_die ... 05-03
SC2181 $? in a following if if cmd; then 03-04
SC2148 Missing shebang #!/usr/bin/env bash 03-01
SC2034 Unused variable Delete it or justify it 03-02
SC2155 local x=$(cmd) masks the code Declare and assign separately 04-02
SC2016 $ inside single quotes Usually correct: silence it 03-06
SC2154 Variable not assigned in the file -x to follow the source 05-06
SC1090/91 source not followable -x -P or # shellcheck source= 05-06
SC2115 rm -rf "$d/" with $d possibly empty ${d:?} 03-06
SC2207 arr=( $(cmd) ) mapfile -t arr < <(cmd) 04-03

  1. Silencing with judgment

Sometimes ShellCheck is wrong, or the warning is correct but you want that behavior. The disable directive has three scopes:

#!/usr/bin/env bash
# shellcheck disable=SC2034            <- file scope, before any code

# The option list MUST be split into words: it is a list of arguments.
# shellcheck disable=SC2086            <- scope of the next command
curl $CURL_OPTIONS "$url"

# shellcheck source=lib/common.sh      <- indicates the source path
source "$BASE_DIR/lib/common.sh"

Plus a .shellcheckrc at the root for team decisions, with source-path=lib and external-sources=true. The rule that has to be enforced: every disable directive comes with a comment justifying it, on the line immediately above. An unjustified disable is worse than the original warning: the warning was at least visible and somebody could investigate it, whereas a silent disable buries it and makes people believe the code has been reviewed. If you silence something because you do not understand it, you have hidden a bug, not fixed it; read the wiki first, because in the vast majority of cases the warning is right.

  1. shfmt: formatting stops being a matter of opinion

In 08-01 we agreed on a style — four spaces, then on the same line — and automating it was left pending. shfmt is the Bash formatter, installable from the package manager or with go install mvdan.cc/sh/v3/cmd/shfmt@latest.

Option Effect
-i 4 Indent with 4 spaces (-i 0 for tabs)
-ci Indent case bodies one extra level
-bn Binary operators (&&, ||) at the start of the next line
-d Shows the diff and exits with 1 if the file is not formatted
-w / -l Rewrites the file / lists the badly formatted ones
$ shfmt -i 4 -ci -d bin/watchdog.sh      # see what it would change
$ shfmt -i 4 -ci -w bin/ lib/            # apply it

With an .editorconfig at the root, shfmt takes its configuration from there and you do not need to repeat the options. The value for a team is twofold: it removes the argument about style — the program decides — and it makes Git diffs contain only real changes, with no reindentation noise. Be careful with one thing: format the whole project once, in its own commit titled "Format the toolkit with shfmt"; mixing formatting and logic in the same commit makes review impossible.

  1. Integration: editor, hook and CI

Three levels, from the most immediate to the most definitive. In the editor is where you save the most time, because the warning appears as you type: VS Code has timonwong.shellcheck and mkhl.shfmt; Vim and Neovim integrate it with ALE or the bash-language-server LSP. In the pre-commit hook (08-04) you stop code with warnings from getting in even if your colleague does not have the extension. And in continuous integration, the only barrier nobody can skip with --no-verify:

# .github/workflows/shell.yml
name: shell
on: [push, pull_request]
jobs:
  analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: ShellCheck
        run: |
          sudo apt-get update && sudo apt-get install -y shellcheck
          shellcheck -x -P lib -S warning bin/*.sh lib/*.sh
      - uses: mfinelli/setup-shfmt@v3
      - run: shfmt -i 4 -ci -d bin/ lib/

Both steps take advantage of the exit code: ShellCheck returns 1 if there are warnings and shfmt -d returns 1 if something is not formatted, so the job fails on its own. -S warning is a conscious decision: we require warning and error, and leave info and style as a recommendation so nobody is blocked over a cosmetic detail.

  1. Other tools

  • checkbashisms (devscripts package): detects Bash constructs in scripts that declare #!/bin/sh. It is the central portability tool and it comes back in 08-07.
  • shellharden: rewrites the code adding the missing quotes; powerful and dangerous in equal measure. bashate: style, more opinionated about formatting than about correctness.
  • bash -n: it is not static analysis, only a syntax check (05-03), but it is free and universal. In the hook it goes before ShellCheck, because a file with one fi too many cannot even be analyzed.

  1. Application: running ShellCheck over the whole toolkit

$ shellcheck -x -P lib -f gcc bin/*.sh lib/common.sh | tee /tmp/warnings.txt | wc -l
47
$ grep -oE 'SC[0-9]+' /tmp/warnings.txt | sort | uniq -c | sort -rn
     19 SC2086
      7 SC2046
      5 SC2155
      4 SC2181
      3 SC2164
      3 SC2034
      2 SC2006
      2 SC2115
      1 SC2207
      1 SC2016

Forty-seven warnings in code we had already reviewed twice. That figure is the whole lesson: human review does not see the missing quotes.

Group Warnings Action
Fix now (real risk) SC2115 (2), SC2164 (3), SC2155 (5) ${d:?}, cd || die, split declaration and assignment
Fix in bulk (mechanical) SC2086 (19), SC2046 (7), SC2006 (2) Quotes, -print0/xargs -0, $(...)
Clean up SC2034 (3), SC2181 (4), SC2207 (1) Delete dead variables, if cmd;, mapfile
Justified silencing SC2016 (1) $3 inside the awk program: directive with a comment

The two SC2115 were the serious find: an rm -rf "$VELOZ_BACKUP_DIR/$backup_date" in backup.sh where, if $backup_date was left empty by a failing date, the command became rm -rf "/srv/veloz/backups/". The fix is three characters and prevents a disaster:

rm -rf -- "${VELOZ_BACKUP_DIR:?}/${backup_date:?}"

With ${var:?} (03-06) the script aborts with a clear message if the variable is empty, instead of deleting the root of the backup. After applying everything and formatting with shfmt -i 4 -ci -w, shellcheck -x -P lib bin/*.sh lib/common.sh passes with no warnings.

Common Mistakes and Tips

  • Running ShellCheck without -x in a project with libraries. It generates dozens of false SC1091 and SC2154 that bury the real warnings.
  • Silencing en masse to "leave it clean". A .shellcheckrc with ten disable entries is not reviewed code, it is code with the warnings turned off.
  • Believing that with no warnings the script is correct. ShellCheck does not know whether your business logic is right; that is what tests are for (08-06).
  • Ignoring info warnings as a matter of policy. SC2086 has info severity and causes half the Bash bugs in production.
  • Tip: start with -S error, then warning, then everything. In a legacy script with 200 warnings, going by severity makes the work manageable.
  • Tip: shellcheck.net lets you paste a fragment and see the warnings without installing anything; never with code containing internal data.

Exercises

Exercise 1. Run ShellCheck mentally over this fragment: list the warnings with their codes and rewrite it fixed.

cd $BACKUP_DIR
files=`find . -name "*.tar.gz" -mtime +30`
for f in $files; do
    rm -f $f
done
grep -q ERROR $LOG
if [ $? -eq 0 ]; then
    lines=$(wc -l < $LOG)
    echo "there are errors: $lines"
fi

Exercise 2. ShellCheck warns of SC2086 in veloz_log_info $message inside lib/common.sh. Explain why it is a real defect and not a false positive, with a concrete Veloz Envíos example.

Exercise 3. Write the CI step that requires both ShellCheck with no warnings of severity warning or higher and correct shfmt formatting, failing the job if either of the two conditions is not met.

Solutions

Solution 1. Warnings: SC2164 (cd with no error handling), SC2086 (on $BACKUP_DIR, $f and $LOG twice), SC2006 (backticks), SC2044/SC2207 (iterating over find's output) and SC2181 ($? in the if).

cd "$BACKUP_DIR" || veloz_die 1 "cannot enter $BACKUP_DIR"

find . -name '*.tar.gz' -mtime +30 -print0 | xargs -0 -r rm -f --

if grep -q ERROR "$LOG"; then
    lines=$(wc -l < "$LOG")
    printf 'there are errors: %s\n' "$lines"
fi

The loop disappears: find -print0 with xargs -0 (05-01) solves word splitting and names with spaces at the same time, and -r avoids running rm when there are no files.

Solution 2. It is a real defect because the message is built from external data. With message="issue in Palma de Mallorca", the function receives five arguments instead of one, and if internally it uses $1 the log will record only "issue". Worse: if the message comes from a line of access.log (08-03) and contains a *, globbing replaces it with the list of files in the current directory and the log becomes useless. The fix is veloz_log_info "$message", and inside the function using "$*" or "$@" deliberately (03-05).

Solution 3.

      - name: Static analysis and formatting
        run: |
          set -euo pipefail
          sudo apt-get update -qq
          sudo apt-get install -y shellcheck
          shellcheck -x -P lib -S warning bin/*.sh lib/*.sh
          shfmt -i 4 -ci -d bin/ lib/

set -euo pipefail (05-03) stops the step at the first command that fails; ShellCheck returns 1 if it finds warnings of warning severity or higher and shfmt -d returns 1 if any file is not formatted, so both conditions translate directly into the job's result without checking anything by hand.

Conclusion

ShellCheck is the tool with the best effort-to-benefit ratio in the whole module: it analyzes the script without running it, takes less than a second and finds exactly the defects Bash will never point out, because the interpreter accepts without protest an rm -rf $dir/* that will work fine until the day a space shows up. It runs over the files and returns 0 or 1, which makes it automatable; -x with -P lib is essential in the toolkit so it follows the library's source calls and does not fill the output with false SC1091 and SC2154, -S warning bounds the severity in legacy projects and -f gcc produces the output editors and CI consume. Every warning brings a file, a line, an underlined fragment, a severity and an SCxxxx code with its wiki page, which is where you really learn. The ones you will see most you already know by their lesson: SC2086 and SC2046 are the quotes from 03-06, SC2164 is the cd without ||, SC2181 is the $? that should have been if cmd;, SC2155 is the local x=$(cmd) that masks the exit code from 04-02, SC2148 the shebang from 03-01 and SC1090/SC1091 the source from 05-06. Silencing is legitimate when the warning is a false positive, but every disable directive carries a comment justifying it, because a silent disable buries a bug and gives the appearance of reviewed code. shfmt -i 4 -ci -w closes what 08-01 promised, turning style into a command instead of an argument, with the advantage that Git diffs stop carrying noise — as long as the initial formatting goes in its own commit. Integration happens at three levels: the editor, where the warning arrives before you save; the pre-commit hook from 08-04; and CI, the only barrier that cannot be skipped. Applying it to the toolkit proves the point: 47 warnings in code already reviewed twice by humans, among them an rm -rf that would have wiped the root of the backup directory if a date failed.

Now the toolkit is clean of formal errors. But ShellCheck does not know whether veloz_percentage 0 0 returns anything sensible, nor whether backup.sh really keeps the thirty days of retention, nor whether the performance refactor from 08-02 changed the report's content. Only a test that runs the code and compares the result against what is expected can tell you that. Lesson 08-06 introduces Bats: what is worth testing in an operations script, how to isolate the test from the environment with temporary directories and fake data, how to replace an external command with a double to test veloz_api_get without an API and backup.sh without touching the disk, and how to write tests/common.bats with real tests of the library's functions.

Bash Programming Course

Module 1: Introduction to Bash

Module 2: Basic Bash Commands

Module 3: Scripting Fundamentals

Module 4: Intermediate Scripting

Module 5: Advanced Scripting Techniques

Module 6: Working with External Tools

Module 7: Automation and Scheduling

Module 8: Best Practices and Optimization

Module 9: Real-World Projects

© Copyright 2026. All rights reserved