Skip to content

Verification tools

Every output below is real, captured by running each tool against patterns from the AI Bug Catalog. None of these tools overlap perfectly, and none of them is close to complete — the point of this lesson is to know exactly where each one’s coverage ends, so you know what is still on you.

go vet — built into go test, catches structural bugs for free

Section titled “go vet — built into go test, catches structural bugs for free”

go vet ships with the Go toolchain and runs automatically before go test. It only reports things it can prove from the type system and a fixed set of analyzers — no style opinions, very few false positives, and no configuration required.

Catches, unconditionally, in this catalog:

copylocks: Inc passes lock by value: main.Counter contains sync.Mutex
waitgroup: WaitGroup.Add called from inside new goroutine
errorsas: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type
main.go:10:8: using resp before checking for errors

Catches only with the go.mod directive it applies to: the loopclosure analyzer flags a goroutine capturing a loop variable — but only under a go directive older than 1.22, since the bug itself only exists there.

Misses: everything that requires proving something about program behavior rather than structure — leaks, nil-map writes that aren’t provably always-nil, missing status-code checks, missing Timeout fields, err shadowing (the shadow analyzer exists but is not run by default — enable it explicitly with go vet -vettool=$(which shadow) or golangci-lint’s govet.enable: [shadow] setting).

staticcheck — deeper static analysis, no false-negative shame

Section titled “staticcheck — deeper static analysis, no false-negative shame”

staticcheck subsumes go vet’s checks and adds hundreds more, organized as SA* (bugs), ST* (style), and other prefixes. It is slower and occasionally flags something intentional, but it catches real bugs go vet structurally cannot.

Catches, in this catalog:

SA4023: this comparison is always true (typed-nil error, when provable)
SA2000: should call wg.Add(1) before starting the goroutine to avoid a race
SA5000: assignment to nil map (only when provably always-nil)
SA4011: ineffective break statement. Did you mean to break out of the outer loop?
SA1004: sleeping for 5 nanoseconds is probably a bug; be explicit if it isn't
ST1006: receiver name should be a reflection of its identity; don't use generic names such as "this" or "self"

Misses: the same runtime-only categories go vet misses, plus anything requiring whole-program knowledge — most notably, whether an interface has one implementation or ten.

golangci-lint v2 — runs go vet, staticcheck, and dozens more in one pass

Section titled “golangci-lint v2 — runs go vet, staticcheck, and dozens more in one pass”

golangci-lint is a meta-linter: linters.default: standard in its config runs a curated set that already includes go vet (as govet) and staticcheck, plus errcheck, unused, and others. One command replaces running each tool by hand.

version: "2"
linters:
default: standard

Catches everything go vet and staticcheck catch above, reported under the linter that found it — golangci-lint run on the errors.As-without-& file reports:

main.go:19:5: errorsas: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type (govet)

Also catches, via errcheck (part of standard) — an unchecked return value, a different bug from the ones it happens to sit next to in this catalog:

main.go:10:23: Error return value of `resp.Body.Close` is not checked (errcheck)

Needs opting in, even though the analyzer exists in golangci-lint’s toolbox:

  • shadow (the err-shadowing check) — enable via linters.settings.govet.enable: [shadow].
  • modernize (flags interface{} where any reads better, among other rewrites) — enable via linters.enable: [modernize].

Misses: identical blind spots to go vet + staticcheck combined, since that is what it runs.

-race — the only tool on this list that watches the program run

Section titled “-race — the only tool on this list that watches the program run”

go run -race and go test -race instrument every memory access and detect actual concurrent, unsynchronized access to the same memory during that specific run — not a theoretical possibility, an observed one.

Catches, in this catalog — the wg.Add-inside-goroutine pattern, run with -race:

WARNING: DATA RACE
Write at 0x00c00013e000 by goroutine 7:
main.main.func1()
.../main.go:18 +0xf0
Previous read at 0x00c00013e000 by main goroutine:
main.main()
.../main.go:24 +0x23c
Found 1 data race(s)

Misses: any race that does not happen to occur on the specific run you tested — the race detector proves the presence of a race it observed, never the absence of one it didn’t. It also has nothing to say about goroutine leaks, resource leaks, or any single-threaded logic bug; those are not races.

govulncheck — known CVEs, filtered to code you actually call

Section titled “govulncheck — known CVEs, filtered to code you actually call”

govulncheck cross-references your dependencies against the Go vulnerability database, then narrows the result to vulnerabilities your code’s call graph can actually reach — not just “a vulnerable version is somewhere in go.sum.”

Real output, a module depending on golang.org/x/[email protected] and calling language.Parse:

Vulnerability #1: GO-2021-0113
Out-of-bounds read in golang.org/x/text/language
More info: https://pkg.go.dev/vuln/GO-2021-0113
Module: golang.org/x/text
Found in: golang.org/x/[email protected]
Fixed in: golang.org/x/[email protected]
Example traces found:
#1: main.go:10:26: vulndemo.main calls language.Parse
Your code is affected by 1 vulnerability from 1 module.
This scan also found 1 vulnerability in packages you import and 2
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.

Misses: anything in this catalog. govulncheck answers one question only — “does a known, published CVE affect code I actually reach” — and has nothing to say about logic bugs, concurrency bugs, or API contract violations, whether they are yours or a dependency’s undiscovered ones.

gopls — the same analysis your editor already runs, from the command line

Section titled “gopls — the same analysis your editor already runs, from the command line”

gopls check <file> runs the same diagnostics your editor shows inline, without opening one — useful in CI or over SSH. It includes go vet’s analyzers and adds a few of its own, like nilness analysis.

Real output, on the errors.As-without-& file — gopls finds both the errors.As misuse and a second, consequential bug in the same file that no other tool in this list reported:

main.go:19:5-23: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type
main.go:20:32: nil dereference in field selection

The second line is gopls’s own nilness analyzer: since errors.As will fail (per the first diagnostic), ve stays nil, and the next line’s ve.Field is a guaranteed nil pointer dereference — a consequence go vet and staticcheck did not connect.

Misses: the same categories as go vet, since it is built on the same analysis framework, plus anything needing whole-program or runtime information.

go test -cover — tells you what was never run, not what is broken

Section titled “go test -cover — tells you what was never run, not what is broken”

go test -cover reports the percentage of statements executed during the test run. It is not a bug finder; it is a map of where your other tools — and your own reading — never even looked.

Real output, a Store.Set method whose only test exercises Get:

ok scratch/covdemo 0.474s coverage: 66.7% of statements

The untested 33.3% is exactly Set — the method that panics on a nil map. Coverage did not find that bug; it found that nobody had looked.

Misses: correctness entirely. 100% coverage proves every line ran at least once during some test — it proves nothing about whether the assertions in that test were the right ones, or whether the input that triggered the bug was ever tried.

golangci-lint run already covers go vet and staticcheck together. Add govulncheck for dependency CVEs and go test -race -cover for runtime races and coverage, and one line covers everything on this page except gopls (which is interactive/editor-oriented, not a CI step):

Terminal window
golangci-lint run && govulncheck ./... && go test -race -cover ./...
flowchart TD
  subgraph Static["Static (source only)"]
    Vet["go vet<br/>structural bugs (type-provable):<br/>copylocks, waitgroup, errorsas, loopclosure"]
    SC["staticcheck (SA*, ST*)<br/>deeper static bugs"]
    Vuln["govulncheck<br/>known CVEs reached by your call graph"]
  end
  subgraph Dynamic["Dynamic (must run)"]
    Race["-race<br/>concurrency bugs that actually occur (only what ran)"]
    Cover["go test -cover<br/>what ran vs. didn't"]
  end
  GCL["golangci-lint runs the whole Static box (go vet + staticcheck + more) in one command"] --> Vet
  GCL --> SC
Static tools prove things from source alone; dynamic tools only see what actually ran
A CI pipeline runs golangci-lint run and it passes with 0 issues. Which of these is still possible?
Why did staticcheck fail with 'package requires newer Go version go1.27' against a module whose code uses no Go 1.27-specific syntax?
gopls reported both an errors.As misuse and 'nil dereference in field selection' two lines later in the same file. What does the second diagnostic depend on?
A file has 100% test coverage. What does that guarantee?