- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A Go repo with no linter drops an ignored error from tx.Commit(),
and it sits in production for months until the day a write silently
disappears. The opposite repo turned on every linter golangci-lint
ships, and now the CI job prints four hundred warnings about missing
struct-field doc comments and nobody reads any of them. Both repos
have the same real defect rate. Only one of them makes noise about it.
The point of a linter is not to have opinions. It's to fail the
build when the code has a bug that a tool can see and a human missed.
That is a small set of checks. This post is the config that turns
that small set on, keeps the noise off, and wires it into CI so it
gates merges instead of decorating them.
Everything here targets golangci-lint v2 (the config schema changed
in v2, released 2025) and Go 1.23+.
Start with the four that find real bugs
golangci-lint bundles dozens of linters. Four of them find actual
defects. The rest are style, and style is where the noise lives.
-
govet— the standardgo vetanalyzers. Printf format mismatches,sync.Mutexcopied by value, unreachable code, struct tags that don't parse. These are bugs, not opinions. -
errcheck— flags every error return value you didn't handle. In Go this is the single highest-value check, because an ignored error is invisible in review and fatal in production. -
staticcheck— the deepest analyzer in the ecosystem. Nil dereferences, impossible type assertions, misusedtime.Duration, loop bugs, incorrectsyncusage. It replaces the oldgosimpleandstylecheck(folded into it in v2). -
ineffassign— assignments whose value is never read. Usually a real mistake: you wrote to the wrong variable, or forgot to use the result.
Here is the minimal .golangci.yml that turns on exactly those and
nothing else:
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- staticcheck
- ineffassign
default: none is the load-bearing line. It disables golangci-lint's
built-in default set so you opt in deliberately. Without it you
inherit whatever the tool's maintainers decided this release, which
is how configs drift into noise.
Run it:
golangci-lint run ./...
On a real codebase the first run finds things. Most of them from
errcheck.
Why errcheck earns its keep
The Go idiom is that errors are values you handle. The compiler does
not force you to. This compiles clean and passes go vet:
func writeAudit(db *sql.DB, msg string) {
db.Exec("INSERT INTO audit(msg) VALUES (?)", msg)
}
The Exec call returns (sql.Result, error). The error is dropped
on the floor. If the insert fails (a constraint violation, a closed
connection, a full disk), this function still reports success and the
audit row never exists. errcheck flags exactly this line.
The fix is to handle it, or to make ignoring it explicit and
searchable:
func writeAudit(db *sql.DB, msg string) error {
_, err := db.Exec(
"INSERT INTO audit(msg) VALUES (?)", msg,
)
return err
}
Sometimes you genuinely want to ignore an error — a Close on a
read-only file, a best-effort w.Write to an already-broken
response. errcheck lets you say so by assigning to _, and you can
whitelist specific functions so those cases don't nag:
linters:
settings:
errcheck:
exclude-functions:
- (net/http.ResponseWriter).Write
- (io.Closer).Close
That is the whole philosophy: the tool flags every ignored error,
and you spend your attention deciding which ones are intentional
instead of hunting for them.
Add a second tier, carefully
Once the four-linter core is green, a few more pay off without
flooding the log. Add them one at a time so you can see what each
one costs in warnings.
-
errorlint— catcheserr == ErrNotFoundwhere a wrapped error would slip past. Once anyone in the codebase writesfmt.Errorf("...: %w", err), every==sentinel check silently breaks.errorlintfinds them and pushes you toerrors.Is. -
bodyclose— flags HTTP response bodies you neverClose. A leakedresp.Bodyholds a connection out of the pool. This is one of the most common real leaks in Go services. -
rowserrcheck/sqlclosecheck— thedatabase/sqlversions of the same idea: uncheckedrows.Err(), unclosed*sql.Rows.
linters:
enable:
- errcheck
- govet
- staticcheck
- ineffassign
- errorlint
- bodyclose
- sqlclosecheck
Notice what is not on this list. No wsl (whitespace opinions), no
funlen (function-length limits), no godox (flagging TODO
comments), no gochecknoglobals. Those are legitimate preferences,
but they are preferences, and a preference that fails CI is a tax on
every contributor who doesn't share it. Keep the merge gate to
things that are defects.
Per-path excludes: stop linting generated and test code the same way
The noise problem is rarely the linter set. It's applying one set
uniformly to code that has different rules. Generated code,
_test.go files, and mocks all trip checks that are irrelevant to
them. golangci-lint v2 handles this with exclusions rules.
Generated files first. Anything with the standard
// Code generated ... DO NOT EDIT. header should be skipped
wholesale — you're not going to fix a linter warning in a file a
tool rewrites:
linters:
exclusions:
generated: lax
rules:
# protobuf and mock output
- path: \.pb\.go$
linters:
- errcheck
- staticcheck
- path: _mock\.go$
linters:
- errcheck
Test files are the other big source of false noise. In tests you
often ignore errors on purpose (_ = json.Unmarshal(...) against a
fixture you control) and you write throwaway HTTP calls. Loosen
errcheck and bodyclose there without turning them off for real
code:
- path: _test\.go$
linters:
- errcheck
- bodyclose
The rule of thumb: when a warning is correct but irrelevant for a
whole class of files, scope the exclude to the path. When it's
correct and irrelevant for one line, use an inline
//nolint:errcheck // reason with an actual reason after it. Bare
//nolint with no linter name and no explanation is how excludes rot.
You can force explanations with a setting:
linters:
settings:
nolintlint:
require-explanation: true
require-specific: true
enable:
- nolintlint
nolintlint lints your lint suppressions. It fails the build on a
//nolint that doesn't name a linter or doesn't say why. That keeps
the escape hatch honest.
The CI gate
Local runs are advisory. The gate is CI, and the gate has to be
reproducible, which means pinning the golangci-lint version. A
config that passes on your laptop and fails in CI because the runner
has a newer linter release is its own kind of noise.
The official GitHub Action pins the version for you:
name: lint
on:
push:
branches: [main]
pull_request:
jobs:
golangci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.1.0
Two things make this a real gate. First, the
explicit version: — everyone runs the same linter, so the build is
deterministic across laptops and CI. Second, the job runs on
pull_request, so a warning blocks the merge instead of showing up
after it lands.
For a repo adopting this on an existing codebase with a backlog of
findings, don't gate the whole history at once. Gate the diff:
- name: golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.1.0
args: --new-from-rev=origin/main
--new-from-rev reports only issues introduced by the current
branch. New code has to be clean; the legacy backlog stays visible
in local runs without blocking every PR on day one. You burn down
the old findings on your own schedule and flip to full-tree gating
once the count hits zero.
The config, assembled
Putting the pieces together, the whole file stays short enough to
read in one screen:
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- staticcheck
- ineffassign
- errorlint
- bodyclose
- sqlclosecheck
- nolintlint
settings:
errcheck:
exclude-functions:
- (io.Closer).Close
nolintlint:
require-explanation: true
require-specific: true
exclusions:
generated: lax
rules:
- path: \.pb\.go$
linters: [errcheck, staticcheck]
- path: _test\.go$
linters: [errcheck, bodyclose]
Eight linters, all of them defect-finders. Two path excludes for the
two file classes that generate false noise. A suppression linter to
keep the escape hatches documented. A CI job that pins the version
and gates the diff.
That is the whole trick. The build fails when the code has a bug a
tool can see. Every other time, it's quiet.
A linter is a floor, not a ceiling. It catches the mechanical
mistakes so review can spend its attention on design. If you want
the deeper why behind the bugs govet and staticcheck flag (the
mutex-copy semantics, the interface internals, how database/sql
resource handles actually behave), that's the ground The Complete
Guide to Go Programming covers. And for keeping error handling and
I/O boundaries clean enough that half these warnings never fire in
the first place, Hexagonal Architecture in Go is the companion on
where those concerns belong in a service.

Top comments (0)