Linters & static analysis
Key takeaways
Static analysis finds bugs by reading code without running it. Go’s
built-in go vet catches legal-but-wrong patterns — mismatched Printf
arguments, copied locks, unreachable code — with near-zero false positives;
staticcheck and bundlers like golangci-lint go deeper. Because analysis
covers all code, not just tested paths, it’s the cheapest layer in the
defense stack: catches at the far-left of the cost curve, before anything
executes. That’s why GopherTrunk’s commit gate is make vet test — vet runs
with the tests, every time.
Unit 4 turns from writing tests to the machinery around them. First tool in the belt: programs that inspect your source the way a sharp-eyed reviewer would — except they read every line, every time, and never get tired.
Finding bugs without running anything
A test needs the code to run, an input chosen, an assertion written. Static analysis needs none of that — it examines the source and flags patterns that are provably or probably wrong. Consider:
fmt.Printf("decoded %d frames on talkgroup %s\n", count)
Valid Go: it compiles. Also definitely broken: two verbs, one argument, and
%s will meet an int if you “fix” it carelessly. No compiler error, and a
test would only catch it if some assertion inspected this exact log line —
which no test does. go vet flags it instantly, because the mistake is visible
in the text of the program itself.
That’s the defining strength: coverage without tests. Your coverage report shows red regions no test executes; the linter reads those too. It checks the error path nobody wrote a test for and the log statement nobody asserts on — for the classes of bug it knows, it checks everything, at the cost-curve’s absolute far left.
What go vet catches
go vet ships with the toolchain and runs in seconds:
go vet ./...
A sampler of its checks, each a real-world bug class:
| Check | The mistake it catches |
|---|---|
printf |
Format verbs that don’t match arguments in number or type |
copylocks |
Copying a struct containing a sync.Mutex — silently forks the lock |
loopclosure |
Goroutine capturing a loop variable that changes under it |
unreachable |
Code after an unconditional return — usually a misplaced brace |
nilfunc |
Comparing a function against nil in a way that’s always true |
shadow (opt-in) |
An inner err := shadowing the outer err — checks the wrong error |
Vet’s design philosophy is why it’s trusted enough to gate commits: it only reports patterns that are almost certainly wrong. When vet speaks, you fix; you don’t debate.
Beyond vet: staticcheck and friends
staticcheck applies hundreds of deeper checks — subtler correctness bugs
(a time.Duration multiplied wrongly, an impossible comparison, a misused
append), plus simplifications and deprecated-API warnings. golangci-lint
bundles vet, staticcheck, and dozens of other linters behind one command and a
per-repo config file, which is how most sizable Go projects run linting in
CI.
The discipline that keeps any of this useful is signal management. A linter
that cries wolf gets ignored, and an ignored linter catches nothing. Enable
checks whose findings you’ll actually fix; when a specific finding is a
considered false positive, suppress it narrowly and visibly (//nolint:
with a reason) rather than disabling the check globally. Warnings that scroll
by unfixed are worse than no linter — they train everyone to skim past the day
a real bug appears in the list.
Rule of thumb: the linter’s output should normally be empty. Zero warnings is a state worth defending, because it makes the first new warning unmissable.
Where linting sits in the defense stack
Each layer catches what the previous can’t, at rising cost:
- Compiler — invalid code. Free, instant, mandatory.
- Linters / static analysis — legal-but-wrong patterns, across all code. Seconds, no tests required.
- Tests — wrong behavior on chosen inputs. Requires writing them.
- Review, CI — what all the machines missed.
GopherTrunk wires layer 2 directly into the everyday loop: the standing rule
is that make vet test — vet plus the unit suite, one command — must be
green before any commit. Vet isn’t a special occasional deep-clean; it’s part
of what “the build is green” means. The full anatomy of that gate is a
Unit 6 lesson. Formatting, the other
machine-enforced hygiene layer, is next.
Quick check: what can a linter check that your test suite structurally cannot?
Recap
- Static analysis finds bugs by reading source — no execution, no inputs, no assertions needed.
go vet: toolchain-standard, fast, near-zero false positives — printf mismatches, copied locks, loop-closure captures, unreachable code.- staticcheck / golangci-lint extend the catalog; keep the enabled set signal-heavy and suppressions narrow.
- Linters check all code, including the never-tested paths — the cheapest catches on the whole cost curve.
- GopherTrunk’s gate is
make vet test: vet runs with the tests, before every commit, always.
Next up: Formatters & style
Frequently asked questions
What's the difference between a linter and a test?
A test runs your code on chosen inputs and checks the results; a linter reads the source without running it and flags patterns that are wrong or suspicious — a Printf with mismatched arguments, an error checked against the wrong variable, an unused result. Tests verify behavior you thought to check; linters catch whole classes of mistakes across all code, including code no test touches.
Is go vet the same as the Go compiler's error checking?
No. The compiler rejects code that isn’t valid Go. go vet accepts valid Go and looks for code that compiles but is probably wrong — like fmt.Printf(“%d”, “hello”), which is legal and broken. Vet ships with the toolchain, is fast, and has almost no false positives, which is why projects like GopherTrunk run it before every commit.
Which linters should a Go project actually run?
Start with go vet — free, fast, near-zero noise. Add staticcheck for a much deeper catalog of correctness and simplification checks. Projects wanting one tool that bundles many linters with per-repo configuration typically adopt golangci-lint. The main discipline is to keep the enabled set signal-heavy — a noisy linter gets ignored, and an ignored linter catches nothing.