go/analysis: the modular static analysis framework from the Go team, in small reusable bricks

Dev & Code 21 h agoAdd to bookmarks

go/analysis: the modular static analysis framework from the Go team, in small reusable bricks
Illustration : Momiji Shirogane

Under the hood of `go vet`, `staticcheck`, and friends: the Go team maintains an official framework that allows you to write a linter in a few dozen lines.

The concrete case

You inherit a 200,000-line Go project. For six months, a code review pattern that causes goroutine leaks has been slipping through: a context.WithTimeout created without defer cancel(). No one sees it when reading. go vet doesn't report it. Adding a rule to the in-house linter would take about a week in another language.

In Go, you write an Analyzer in an evening. Because the language team has long maintained a quasi-discreet but fundamental package: golang.org/x/tools/go/analysis.

Why it's important

The go/analysis package ("Go Analysis Framework") is not new - it is published and documented at pkg.go.dev/golang.org/x/tools/go/analysis - but it regularly resurfaces on Hacker News (last resurgence on 2026-07-26) because many Go developers still don't know about it. Yet it is the infrastructure on which the language's static analysis ecosystem runs: go vet relies on it, staticcheck (Dominik Honnef) uses it, gopls (the official language server) connects its diagnostics to it, and the vast majority of modern CI linters - golangci-lint in the lead - are just collections of Analyzers stacked together.

The framework's bet is simple: one analyzer = one struct. This struct declares its name, its documentation, its dependencies on other analyzers, and a Run function that receives a *analysis.Pass object (the parsed code, the type checker, the files, the comments) and reports its diagnostics via pass.Report. That's all.

How it works under the hood

Three ideas drive the architecture:

  1. Modularity through composition. An analyzer can declare Requires: []*analysis.Analyzer{inspect.Analyzer}: the framework resolves the dependency graph and injects the results of the prerequisite analyzers into the Pass.ResultOf. Result: your business analyzer does not re-parse the files, it consumes the result of inspect (which, in turn, does the typed AST walk once for the entire chain).

  2. One "driver", multiple bootstrappings. You write your Analyzer. singlechecker.Main(myAnalyzer) makes it a standalone CLI binary, multichecker.Main(a1, a2, a3) makes it a multi-check binary, unitchecker.Main(...) connects it to go vet -vettool=.... Zero glue code: the same Analyzer code runs locally, in CI, in the IDE via gopls, and in golangci-lint.

  3. Typed automatic fix. A diagnostic can carry SuggestedFixes: textual replacements anchored on token.Pos positions. gopls applies them with one click in the editor. This is what allows, for example, staticcheck to suggest "replace errors.Is(err, sql.ErrNoRows) with a more idiomatic check" and the IDE to make the substitution properly.

To give an order of magnitude: singlechecker - the function that turns an *Analyzer into a complete binary - fits in one file. The minimal skeleton of a publishable Go linter is roughly 40 lines, doc and tests included. Compare that to writing an ESLint plugin from scratch: it's not the same scale.

What it changes in practice

Three uses we see in real projects:

  • Internal company rules. "It's forbidden to directly import net/http, you have to go through our tracing wrapper." Thirty lines, an Analyzer that inspects the *ast.ImportSpec, a test with analysistest.Run on a fixture file, and the rule is connected to CI the next day.
  • Massive code migration. Large-scale refactoring (renaming a deprecated API in 500 files): an Analyzer with SuggestedFix turns the operation into go vet -vettool=./monfix -fix ./.... Zero humans in the loop.
  • Custom dataflow analysis. The framework provides companion packages - ctrlflow (control flow graph), buildssa (SSA representation of Go code) - on which non-trivial dataflow analyses can be connected. Detecting leaks of unclosed sql.Rows, for example, is done seriously from the SSA, not from the raw AST.

The limits we must know

Two blind spots to keep in mind:

  • Intra-package analysis. By default, an Analyzer sees one package at a time. Inter-package analyses (whole-program) require additional work - it's possible with "facts" (analysis.Fact), which allow an analyzer to export facts about the symbols of a package so that these facts are reused when another package imports it, but it's a more complex level to write.
  • No dynamic plugin ecosystem. You don't "plug" an Analyzer into go vet via runtime config: you compile it into a binary. It's a choice (Go doesn't like dynamic plugins), not an oversight.

To remember

  • go/analysis is the official API for everything related to static analysis in Go, from the in-house linter to automatic code migration.
  • One analyzer = one struct with a Run(*Pass). The rest (CLI, go vet integration, gopls integration, CI) is offered to you.
  • If you're still writing your code quality rules in shell or sed, that evening is well invested.
Resources, try it

Article produced by artificial intelligence, reviewed under human editorial control.

Our newsroom
Was this article helpful?

3 people liked this article

Like
K
Kaito KuroganeSenior Dev Writer
Senior polyvalent developer, backend Go + frontend TS, open source contributor.
Share:
LIVERadio Geek Kitsune
Tap to listen, the same sound for everyone
0··
// Schedule
// all stations
// share a track →
Topics
Explore
Information