golangci-lint — Professional¶
1. Project structure¶
golangci-lint is itself a Go module. The interesting pieces:
golangci-lint/
├── cmd/golangci-lint/ # CLI entry
├── pkg/golinters/ # one wrapper subpackage per third-party linter
├── pkg/lint/ # core: runner, loader, cache, output
├── pkg/result/processors/ # filters: nolint, exclude, severity, autogen
├── pkg/config/ # .golangci.yml schema + validation
└── pkg/commands/ # subcommands: run, linters, cache, config
Each wrapper in pkg/golinters/<name> imports the upstream linter as a normal Go dependency and exposes one or more *analysis.Analyzer (or adapts a non-analysis linter into one). That is why the binary statically embeds every supported linter — there is no dynamic plugin loading in the default build.
2. Loading packages once¶
The loader uses golang.org/x/tools/go/packages with LoadAllSyntax mode, which yields:
- Parsed AST (
*ast.File) for each file. - Full
*types.Infoand*types.Package. - Module/build context resolved.
This load happens once per golangci-lint run invocation. Every enabled analyzer then receives an *analysis.Pass referring to the same *types.Package and []*ast.File. Linters that need SSA form (gosec, parts of staticcheck) request the buildssa.Analyzer as a dependency; SSA is also built once and shared.
The package graph is walked in dependency order so that, e.g., unused (which needs to know whole-program usage) gets results from upstream analyzers it depends on.
3. The cache¶
~/.cache/golangci-lint/ # $GOLANGCI_LINT_CACHE
├── v1.59.1/
│ ├── kvstore.gob # mapping cache
│ └── per-package/
│ └── <hash>/ # analysis results
A cache entry key combines:
golangci-lintversion.- Go toolchain version.
- Set of enabled linters and their merged settings.
- Hash of every input source file in the package and its dependencies.
- Build tags.
On a hit, the per-package analyzer results are read straight from disk and merged. On a miss, the loader runs and the new result is persisted. Subcommands:
In CI, persisting ~/.cache/golangci-lint plus ~/.cache/go-build and ~/go/pkg/mod cuts a typical lint job from minutes to seconds.
4. The go/analysis adapter¶
The framework analysis.Analyzer has roughly this shape:
type Analyzer struct {
Name string
Doc string
Requires []*Analyzer
Run func(*Pass) (interface{}, error)
ResultType reflect.Type
Flags flag.FlagSet
}
golangci-lint instantiates each enabled analyzer, registers it with an internal runner, and supplies a *Pass per package. The runner takes care of:
- Topologically sorting analyzers.
- Memoizing
Resultper(analyzer, package). - Routing
Pass.Report/Pass.Reportfcalls into an internal*result.Issuestream. - Applying processors:
nolintcomments,exclude-rules, severity mapping, autogen-file detection, max issues per linter, etc.
For non-go/analysis upstream tools, a thin wrapper adapts them by collecting their findings and emitting Issue records — they still benefit from the cache and exclude pipeline, just not the shared type-check pass.
5. How --fix works¶
--fix asks each linter to provide a textual diff (analysis.SuggestedFix) and applies it to disk:
Only a subset of linters can fix: gofmt, goimports, gofumpt, misspell, parts of gocritic, whitespace, and a few more. The runner:
- Collects all
SuggestedFixrecords per file. - Sorts them by start offset (descending), so applying does not invalidate earlier offsets.
- Rewrites the file atomically (write to temp, rename).
Conflicting fixes from different linters on the same range are skipped with a warning — you may have to re-run after fixing the conflict manually.
6. Debug mode¶
golangci-lint run -v # verbose: which packages, linters, timings
golangci-lint run -v --print-resources-usage # peak RSS, GC stats per linter
golangci-lint cache status # inspect cache
GOLANGCI_LINT_CACHE=/tmp/lc golangci-lint run # isolate cache for an experiment
-v prints a per-linter elapsed time table at the end, which is the right starting point when "lint is slow": one linter usually dominates.
INFO [runner] Issues before processing: 312, after processing: 14
INFO [runner] processing took 28ms with stages: max_same_issues: 11ms, nolint: 6ms, ...
INFO [runner] linters took 3.2s with stages: staticcheck: 1.8s, gosec: 0.6s, ...
7. Writing a custom linter¶
Two integration paths:
Plugin (.so) — v1 model.
package main
import "golang.org/x/tools/go/analysis"
var Analyzer = &analysis.Analyzer{
Name: "noprintln",
Doc: "disallow fmt.Println in production code",
Run: run,
}
func run(pass *analysis.Pass) (any, error) {
// walk pass.Files, report uses of fmt.Println
return nil, nil
}
type analyzerPlugin struct{}
func (analyzerPlugin) GetAnalyzers() []*analysis.Analyzer { return []*analysis.Analyzer{Analyzer} }
var AnalyzerPlugin analyzerPlugin
Build with go build -buildmode=plugin -o noprintln.so . and point the custom section at the .so. Fragile: must be built with the exact same Go version and architecture as the golangci-lint binary.
Plugin module — v2 model.
You declare your analyzers in a Go module and run golangci-lint custom, which generates a small main package, compiles it together with the standard linters into a new golangci-lint binary. The result is a single static executable with your analyzer baked in — no .so headaches, works cross-platform, recommended for CI.
8. Memory characteristics¶
A run loads the full type graph of every linted package into one process. Rough numbers on a 500-package monorepo:
| Scenario | Peak RSS |
|---|---|
go vet alone | ~600 MB |
golangci-lint run with 8 linters | ~2.5 GB |
Same, with unused enabled (whole-program analysis) | ~4 GB |
unused and gosec are the usual memory hot-spots — they need cross-package or SSA information. Mitigations:
- Set
run.concurrencylower; concurrency = peak parallel package analyses, each holding state. - Split the run by sub-tree in CI (
golangci-lint run ./internal/...and./cmd/...in parallel jobs). - Disable
unusedon extreme repos and rely ondeadcode+unparaminstead (cheaper, narrower).
Watch for OOM-kills in CI containers: a default 1 GB container will not finish a unused-enabled run on a large repo.
9. Summary¶
Internally, golangci-lint is a go/packages loader plus a go/analysis runner with a disk cache, wrapping each upstream linter as a normal Go dependency. The cache (~/.cache/golangci-lint) is keyed on tool/Go versions, enabled linter set and settings, and source hashes. --fix works by collecting SuggestedFix records and rewriting files atomically; conflicts are skipped. Custom linters integrate via the v1 .so plugin or, preferably, the v2 plugin module that emits a custom binary. Memory scales with package count and whether whole-program analyzers (unused) are on — plan CI resources accordingly.
Further reading¶
- Source: https://github.com/golangci/golangci-lint
go/packages: https://pkg.go.dev/golang.org/x/tools/go/packagesgo/analysisframework: https://pkg.go.dev/golang.org/x/tools/go/analysis- Custom linters: https://golangci-lint.run/plugins/