DEV Community

Cover image for golangci-lint in 2026: A Config That Catches Bugs Without the Noise
Gabriel Anhaia
Gabriel Anhaia

Posted on

golangci-lint in 2026: A Config That Catches Bugs Without the Noise


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 standard go vet analyzers. Printf format mismatches, sync.Mutex copied 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, misused time.Duration, loop bugs, incorrect sync usage. It replaces the old gosimple and stylecheck (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
Enter fullscreen mode Exit fullscreen mode

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 ./...
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 — catches err == ErrNotFound where a wrapped error would slip past. Once anyone in the codebase writes fmt.Errorf("...: %w", err), every == sentinel check silently breaks. errorlint finds them and pushes you to errors.Is.
  • bodyclose — flags HTTP response bodies you never Close. A leaked resp.Body holds a connection out of the pool. This is one of the most common real leaks in Go services.
  • rowserrcheck / sqlclosecheck — the database/sql versions of the same idea: unchecked rows.Err(), unclosed *sql.Rows.
linters:
  enable:
    - errcheck
    - govet
    - staticcheck
    - ineffassign
    - errorlint
    - bodyclose
    - sqlclosecheck
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

--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]
Enter fullscreen mode Exit fullscreen mode

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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)