golangci-lint — Senior¶
1. Architecture: one analysis, many linters¶
Most modern Go linters are written against the golang.org/x/tools/go/analysis framework. An analyzer declares its dependencies (other analyzers' results) and a single Run(*Pass) (any, error) function. golangci-lint is a driver over this framework:
- Load packages once via
go/packages(parses + type-checks). - Build the analyzer DAG from every enabled linter.
- Run analyzers in topological order, reusing the type information, SSA form, and intermediate results across linters.
- Collect issues, deduplicate, filter via
issues.exclude-rules, and print.
The win is that govet, staticcheck, errcheck, unused, and friends do not each re-parse and re-type-check your code — that work happens once for all of them.
2. The linter cache¶
golangci-lint keeps its own cache at $GOLANGCI_LINT_CACHE (default ~/.cache/golangci-lint):
~/.cache/golangci-lint/
├── v1.59.1/
│ ├── analysis/ # per-(package, analyzer-set) results
│ └── packages/ # packages.Load output
Cache key inputs include: golangci-lint version, Go version, set of enabled linters, their settings, and the package content hashes. Change any of those and the entry is invalidated. In CI, you want to persist this directory between runs — a cold cache on a large repo can mean minutes of re-analysis.
3. Designing .golangci.yml for a large team¶
Principles:
- Least surprise.
disable-all: trueplus explicitenable:— never inherit defaults from a future release. - Gradual adoption. Introduce strict linters with
severity: warningfirst, flip toerrorlater. - Per-path rules. Tests and generated code rarely deserve the same checks as production code.
- Document why each rule is on or off, inline with comments.
linters:
disable-all: true
enable:
- govet
- staticcheck
- errcheck
- ineffassign
- unused
- gocritic
- revive
- gosec
# - dupl # too noisy on table tests, revisit Q4
# - gocognit # planned: enable after refactor of /internal/billing
issues:
exclude-rules:
- path: _test\.go
linters: [errcheck, gosec, dupl]
- path: ^cmd/migrate/
linters: [gocyclo, gocognit] # generated migration code
- text: "G404" # math/rand for non-crypto IDs is fine
linters: [gosec]
severity:
default-severity: error
rules:
- severity: warning
linters: [gocritic, revive]
Code review the lint config the same way you review code: every exclude has an owner and a comment.
4. Gradual adoption with --new-from-rev¶
For repos with a long history of un-linted code, the safe rollout pattern is:
- Land the
.golangci.ymlwith full linter set enabled. - In CI, run with
--new-from-rev=origin/main. Existing findings are ignored; only PR-introduced findings fail the build. - Optionally, run a separate non-blocking job that lints the whole repo and reports the count — gives a "debt" trend.
- Fix the legacy backlog opportunistically; the day the count hits zero, drop
--new-from-rev.
# .github/workflows/lint.yml
- name: lint changed
run: golangci-lint run --new-from-rev=origin/${{ github.base_ref }} --out-format=github-actions
- name: lint all (informational)
if: always()
run: golangci-lint run --issues-exit-code=0 --out-format=json:lint-debt.json
--new-from-rev requires the full git history of the base branch in CI (fetch-depth: 0).
5. Version pinning is non-optional¶
Every new golangci-lint release does at least one of: - Adds a new linter to a preset. - Bumps a bundled linter (e.g., new staticcheck checks). - Changes default settings.
Any of those creates new findings on code that previously passed. Therefore:
- Pin in CI (
v1.59.1, notlatest). - Pin in dev — document the exact version in the README and Makefile.
- Bump deliberately — a separate PR, with the diff in findings discussed in code review.
GOLANGCI_LINT_VERSION := v1.59.1
lint:
@golangci-lint version | grep $(GOLANGCI_LINT_VERSION) >/dev/null \
|| (echo "wrong golangci-lint version, want $(GOLANGCI_LINT_VERSION)" && exit 1)
golangci-lint run ./...
6. v1 → v2 config migration¶
golangci-lint v2 reworks the config schema. Highlights: - linters.enable and linters.disable move under a single linters: block with default: standard|none|all. - linters-settings becomes linters.settings. - issues.exclude-rules becomes linters.exclusions.rules. - Per-format output keys are restructured (output.formats is a map).
The migrator does most of it:
golangci-lint migrate # rewrites .golangci.yml in place
golangci-lint migrate --dry-run # preview only
Plan the migration as a single PR after pinning the v2 version, and re-baseline --new-from-rev against the new findings (typically a handful change).
7. Integrating custom linters via the custom section¶
You can plug a domain-specific linter into the same driver, config, and output pipeline:
linters:
enable:
- mycorp-noprintln
linters-settings:
custom:
mycorp-noprintln:
path: ./tools/linters/noprintln.so # built as a Go plugin
description: forbid fmt.Println in production code
original-url: github.com/mycorp/lint/noprintln
The plugin must export an AnalyzerPlugin symbol that returns []*analysis.Analyzer. v2 also supports a plugin module mode (no .so build required) where you compile a custom golangci-lint binary that bakes in your analyzers — preferred for cross-platform CI, since .so plugins are platform-specific and ABI-fragile.
8. Summary¶
The architectural value of golangci-lint is the one-load, many-analyzers model on top of go/analysis, plus a disk cache. For a large team, the corresponding discipline is: pin the version, use disable-all plus explicit enables, document each exclude, roll strict configs out with --new-from-rev, and treat the v1→v2 migration as a single planned change. Custom linters slot into the same pipeline via the custom section or the v2 plugin module.
Further reading¶
go/analysisframework: https://pkg.go.dev/golang.org/x/tools/go/analysis- Plugin system: https://golangci-lint.run/plugins/
- v2 migration guide: https://golangci-lint.run/product/migration-guide/