ShellCheck and Linting
shellcheck is one of the best static analysis tools ever written — for any language. It reads your script and finds the bugs you know exist but forgot to look for: unquoted variables that will break on spaces, $(cmd) inside single quotes that won't expand, [[ $x == "foo" ]] with a useless double-quoted pattern, for x in $(ls) word-splitting disasters.
It is free, open source, fast, and widely available. If your team is not running ShellCheck on every shell script in every PR, the single highest-leverage change you can make in a week is to set that up.
Install ShellCheck. Run it on every shell script. Fix every warning. That single step eliminates the majority of the bugs covered in this course before they reach production.
What ShellCheck catches
The categories of issues ShellCheck finds:
1. Quoting bugs
# SC2086: Double quote to prevent globbing and word splitting.
rm $file
# SC2046: Quote this to prevent word splitting.
tar -czf $(date +%Y%m%d).tar.gz ./data
2. Shell-level typos
# SC2028: echo won't expand escape sequences.
echo "hello\nworld" # prints literal "hello\nworld"
# Fix: use printf or $'hello\nworld'
3. [ ] vs [[ ]] mistakes
# SC2075: Escaping < inside [[..]] is the same as escaping it inside " "
[[ $a \< $b ]]
# SC2166: Prefer [ p ] && [ q ] as [ p -a q ] is not well defined.
[ -f "$f" -a -r "$f" ]
4. Subshell gotchas
# SC2031: `var` was modified in a subshell. That change might be lost.
count=0
ls | while read f; do count=$((count+1)); done
echo $count
5. Useless uses of common tools
# SC2002: Useless use of cat. Consider 'cmd < file | ..' or 'cmd file | ..' instead.
cat file | grep pattern
6. Typos in variable names
# SC2154: `nmae` is referenced but not assigned.
name="alice"
echo "$nmae" # obvious typo
7. Shebang issues
# SC1090: Can't follow non-constant source. Use a directive to specify location.
source "$CONFIG_FILE"
8. Many more
ShellCheck has over 300 checks. Browse shellcheck.net for the full list.
Installing ShellCheck
# macOS
brew install shellcheck
# Ubuntu / Debian
apt-get install shellcheck
# Fedora
dnf install ShellCheck
# Docker (no local install)
docker run --rm -v "$PWD:/src" koalaman/shellcheck:stable /src/script.sh
Running ShellCheck
Basic usage:
shellcheck script.sh
Output looks like:
In script.sh line 5:
rm $file
^-- SC2086: Double quote to prevent globbing and word splitting.
Did you mean:
rm "$file"
Each warning has:
- Line number and context.
- Warning code (
SC2086). - Human-readable description.
- Often a suggested fix.
Running on multiple files
shellcheck *.sh
shellcheck scripts/*.sh
find . -name '*.sh' -exec shellcheck {} +
Failing on warnings
By default, ShellCheck exits non-zero if it finds issues. Perfect for CI:
shellcheck script.sh || exit 1
Severity levels
shellcheck --severity=error script.sh # only errors
shellcheck --severity=warning script.sh # errors + warnings
shellcheck --severity=info script.sh # + info (default-ish)
shellcheck --severity=style script.sh # + style suggestions (strictest)
Start with --severity=warning in CI. Upgrade to info or style once the warnings are clean.
Reading warnings — the common ones
Five codes that cover 80% of ShellCheck output:
SC2086: "Double quote to prevent globbing and word splitting"
# WARN
rm $file
# FIX
rm "$file"
The most common warning. Every unquoted variable earns this. It's not always a bug, but quoting is almost always safer.
SC2046: "Quote this to prevent word splitting"
# WARN
tar -czf $(date +%Y%m%d).tar.gz data/
# FIX
tar -czf "$(date +%Y%m%d).tar.gz" data/
Command substitutions get word-split just like variables.
SC2155: "Declare and assign separately to avoid masking return values"
# WARN — $? is the exit code of `local`, not of risky_call
local x=$(risky_call)
if [[ $? -ne 0 ]]; then ...
# FIX
local x
x=$(risky_call)
if [[ $? -ne 0 ]]; then ...
Covered in the Functions lesson. ShellCheck catches this reliably.
SC2181: "Check exit code directly, not indirectly"
# WARN
cmd
if [[ $? -ne 0 ]]; then
echo "failed"
fi
# FIX
if ! cmd; then
echo "failed"
fi
Cleaner and also avoids a subtle bug where any command between cmd and the check would change $?.
SC2148: "Tips depend on target shell; shebang is missing"
# WARN (no shebang)
echo "hi"
# FIX
#!/usr/bin/env bash
echo "hi"
Scripts without a shebang are ambiguous — sh, bash, zsh all behave differently. Always declare.
Silencing warnings you've judged OK
Sometimes ShellCheck warns about something you've deliberately done. Suppress per-line:
# shellcheck disable=SC2086
rm $file # intentional word splitting: $file contains multiple space-separated targets
Or for a block:
# shellcheck disable=SC2086
{
rm $a
rm $b
}
Or project-wide via .shellcheckrc:
# .shellcheckrc
disable=SC2086
Silencing warnings should be rare. Each silenced warning deserves a comment explaining why. If you end up silencing the same code everywhere, reconsider whether you're actually avoiding the bug it warns about.
Integrating with source and external files
Scripts that source other files confuse ShellCheck because it doesn't know which file is being sourced:
# SC1090: Can't follow non-constant source.
source "$CONFIG_FILE"
Tell it with a directive:
# shellcheck source=./lib/config.sh
source "$CONFIG_FILE"
Or disable that specific check when the path is genuinely dynamic:
# shellcheck disable=SC1090
source "$dynamic_path"
Following source files
shellcheck -x script.sh
-x makes ShellCheck follow source commands and check the sourced files too. Useful for catching bugs in libraries.
Pre-commit integration
The simplest way to make ShellCheck part of daily life: pre-commit.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.9.0.6
hooks:
- id: shellcheck
args: ['--severity=warning']
Then:
pre-commit install
Every git commit now runs ShellCheck on any changed .sh files. If it fails, the commit is blocked.
Using the system shellcheck
If you prefer not to use the Python wrapper:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: shellcheck
name: shellcheck
entry: shellcheck
language: system
types: [shell]
CI integration
GitHub Actions
# .github/workflows/shellcheck.yml
name: ShellCheck
on: [push, pull_request]
jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ludeeus/action-shellcheck@master
with:
severity: warning
GitLab CI
shellcheck:
image: koalaman/shellcheck-alpine:stable
script:
- shellcheck --severity=warning scripts/*.sh
Simple CI (any platform)
#!/bin/bash
set -euo pipefail
fail=0
while IFS= read -r -d '' file; do
shellcheck --severity=warning "$file" || fail=1
done < <(find . -type f \( -name '*.sh' -o -name '*.bash' \) -print0)
exit $fail
Run that as a CI step on every PR.
Editor integration
ShellCheck runs inside most editors with the right plugin. It makes the "fix as you type" experience real.
- VS Code: "ShellCheck" extension.
- Vim: ALE, Syntastic, coc.nvim all integrate it.
- JetBrains (IntelliJ, PyCharm): built-in shell support.
- Emacs: flycheck includes a checker.
Most developers get the biggest gains from editor integration — you see the warning immediately, not at commit time.
.shellcheckrc — project-wide config
A .shellcheckrc in the repo root lets you set defaults:
# .shellcheckrc
# Use bash as the shell dialect even for files with a sh shebang
# (opinionated — use only if all your scripts really are bash)
# shell=bash
# Severity floor
severity=warning
# Globally disable a few warnings
disable=SC2155,SC2034
# Enable external file following
external-sources=true
Commit this so every developer and every CI run uses the same config.
Other shell linters
ShellCheck is the clear leader, but a few others exist:
bashate— Python-style style checker for bash. Complements ShellCheck (it focuses on style; ShellCheck on correctness).shfmt— formatter (not linter). Likegofmtfor bash — ensures consistent indentation and spacing.
shfmt
# Format in place
shfmt -w script.sh
# Check only (CI)
shfmt -d script.sh # shows diff; non-zero exit if not formatted
Pair shellcheck (correctness) with shfmt (formatting). Together they eliminate most debates about Bash style.
A realistic fix-ShellCheck workflow
For a repo with a backlog of shellcheck warnings:
-
Count the warnings.
shellcheck scripts/*.sh | grep -c '^In 'tells you the scope. -
Sort by code. Most warnings are the same few codes (SC2086, SC2046). Fix those first with a repo-wide pass.
-
Pick a severity floor that represents "zero new issues" — start with
--severity=error, move towarning, theninfo. -
Add pre-commit + CI to prevent regression. At this point, no new warnings can be added.
-
Chip away at the remaining warnings. Assign to owners. Burn down over weeks.
-
Tighten the severity over time. Eventually fail CI on
--severity=info.
Going from "no shellcheck" to "strict shellcheck in CI" inside a month is realistic for any repo.
Do NOT try to fix all warnings in a single giant PR. Incremental PRs (one warning type at a time, or per-directory) are much easier to review and revert if needed.
Real-world example
Here's a script before and after ShellCheck:
Before (warnings everywhere):
#!/bin/bash
files=`ls /tmp/*.log`
for f in $files; do
size=`du -b $f | awk '{print $1}'`
if [ $size -gt 1024 ]; then
gzip $f
fi
done
ShellCheck says:
SC2006: Use $(...) notation instead of legacy backticked `...`.
SC2034: files appears unused. Verify use (or export if used externally).
SC2045: Iterating over ls output is fragile. Use globs.
SC2086: Double quote to prevent globbing and word splitting.
After:
#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob
for f in /tmp/*.log; do
size=$(stat -c%s "$f") # size in bytes
if (( size > 1024 )); then
gzip "$f"
fi
done
Every ShellCheck warning addressed. Also more idiomatic: globbing instead of ls, $(...) instead of backticks, quoted $f, (( )) for numeric comparison.
Quiz
You get this warning: SC2086: Double quote to prevent globbing and word splitting. The variable in question is guaranteed to be a simple integer from within your own code. Should you silence the warning?
What to take away
- ShellCheck is free, fast, and finds real bugs. Every shell script in every repo should be linted.
- Install locally (
brew install shellcheck,apt-get install shellcheck). Integrate with your editor for immediate feedback. - Wire into pre-commit and CI. New warnings should not be mergeable.
- Start with
--severity=warning; move toinfoorstyleover time. - Silence warnings rarely, and always with a comment explaining why.
- Use directives for
sourcepaths:# shellcheck source=./lib/config.sh. - Combine with
shfmtfor consistent formatting. - For existing repos: incremental cleanup, not big-bang fixes.
Next module: Makefiles — the build system every professional repo uses, finally explained from first principles.