Skip to content

A Deep Dive into golangci-lint

golangci-lint is one of the highest-leverage tools in the Go ecosystem because it turns a loose collection of static analysis tools into a single, fast, repeatable code-quality gate. Used well, it catches real bugs, reduces review noise, and helps maintainers keep a project consistent without turning style preferences into endless pull request commentary.

For many Go teams, the mistake is not adopting golangci-lint. The mistake is adopting it without a strategy. Enabling too many linters at once, tolerating unexplained //nolint comments, or treating the default output as a substitute for judgment quickly turns a useful signal into background noise.

This post walks through how golangci-lint works, how I think about configuring it for a real project, and how to run it in ways that are practical for both day-to-day development and long-term open source maintenance.

Much of what I know about golangci-lint was learned the unglamorous way: maintaining Go-based open source projects where lint output has to hold up in front of contributors, CI systems, release processes, and real users. That includes work across HashiCorp Terraform providers, HashiCorp Packer plugins, and Go SDKs.

If you want the broader project list behind that perspective, see the Open Source Projects section of my resume.

That context matters because this is not an abstract "here are the docs" walkthrough. It is an opinionated maintainer's view of what actually keeps linting useful in a long-lived Go codebase.

What golangci-lint Actually Is

golangci-lint is not a linter in the singular sense. It is a runner and orchestrator for many Go linters and analyzers behind one command, one cache, and one configuration file. Instead of asking contributors to install and invoke staticcheck, govet, errcheck, revive, gocritic, and a handful of other tools separately, you give them one interface:

golangci-lint run

That matters more than it sounds. The value is not only convenience:

  • It centralizes configuration in one place.
  • It normalizes output across multiple analyzers.
  • It reduces CI plumbing.
  • It makes local developer workflows and CI workflows match.
  • It gives maintainers one place to tune strictness over time.

In an open source project, that consistency matters a lot. The easier it is for contributors to run the exact same checks locally that CI will run later, the less time maintainers spend explaining avoidable failures.

How It Works Under the Hood

At a high level, golangci-lint does four jobs:

  1. It discovers the packages to analyze.
  2. It loads package metadata and type information through the Go toolchain.
  3. It runs the enabled analyzers.
  4. It collects, deduplicates, filters, and reports issues.

This architecture explains several behaviors that surprise people the first time they use it.

It Depends on a Healthy Go Build Context

golangci-lint is only as successful as its ability to load your code the way go would. If your module graph is broken, build tags are wrong, generated files are missing, or the selected Go version does not match the module, linting often fails before any linter has a chance to say something useful.

That is why lint failures sometimes look like build failures:

could not load export data
typecheck errors
no export data for "example.com/project/internal/foo"

When you see errors like these, treat them as environment or package-loading problems first, not as a golangci-lint bug.

The Cache Is a Big Part of Why It Feels Fast

Running several analyzers separately would repeat package loading and analysis work. One of the reasons golangci-lint is practical on large repositories is that it shares work and caches results aggressively. A warm cache makes repeat runs dramatically faster, which is why it fits well in editor integrations, pre-commit hooks, and local make lint targets.

It Is a Policy Layer, Not Just a Tool Wrapper

The most important mental model is this: golangci-lint is where repository policy lives. The tool decides:

  • Which linters are authoritative for this repo
  • Which directories or files are excluded
  • Which warnings are allowed in tests, generated code, or migrations
  • Which suppressions are acceptable
  • Whether CI should fail on all issues or only newly introduced ones

That is why the configuration deserves real thought.

Choosing Linters on Purpose

The worst golangci-lint configuration is usually one of these two extremes:

  • Enable almost nothing and miss obvious problems.
  • Enable everything and train the team to ignore the output.

The right approach is a deliberate starter set with a clear reason for each linter.

A Strong Baseline

For most Go projects, this is a solid starting point:

  • govet: catches suspicious constructs the Go team itself considers worth flagging
  • staticcheck: finds correctness issues and non-idiomatic code with a strong signal-to-noise ratio
  • errcheck: catches ignored errors, which are one of the most common classes of Go bugs
  • ineffassign: finds assignments whose values are never used
  • unused: catches dead code and unused declarations
  • gosimple: points out simpler, more idiomatic equivalents
  • revive: configurable style and maintainability checks, and the practical replacement for deprecated golint

That set is strict enough to improve code quality and still realistic for an open source project that accepts outside contributions.

Add More Only When the Repository Benefits

Additional linters can be excellent, but they should be justified:

  • gocritic: useful for deeper code-quality heuristics, but broad enough that teams should review findings carefully
  • gosec: worth considering on public-facing projects and provider or plugin codebases, but it needs careful tuning to avoid normalizing false positives
  • misspell: low-friction polish for comments, docs, and user-facing strings
  • unconvert: catches redundant conversions
  • unparam: useful in libraries and mature codebases, but noisy during active refactoring
  • stylecheck: good when you want stricter idiomatic guidance beyond correctness

Of these, gosec deserves special mention. In infrastructure tooling and SDK-adjacent repositories, security-focused checks can catch genuinely important mistakes. They can also generate findings that require human judgment about cryptography, test fixtures, or acceptable risk. That makes gosec a strong candidate for mature public repositories, but not something I enable casually without reviewing the resulting signal first.

Avoid the 'Enable Everything!' Trap

A linter that your team routinely ignores is worse than a linter you never enabled. It creates alert fatigue, encourages sloppy suppression habits, and erodes trust in the checks that actually matter.

A Practical .golangci.yml

As of today, version: "2" is the current golangci-lint configuration format. This is the kind of configuration I recommend as a starting point for a serious Go project:

---
# .golangci.yml
version: "2"

linters:
  default: none
  enable:
    - errcheck
    - govet
    - gosimple
    - ineffassign
    - revive
    - staticcheck
    - unused
  settings:
    errcheck:
      check-type-assertions: true
      check-blank: true
    revive:
      rules:
        - name: exported
        - name: package-comments
  exclusions:
    generated: lax
    rules:
      - path: _test\\.go
        linters:
          - errcheck
        text: "Error return value of .* is not checked"
      - path: "internal/generated/.*\\.go"
        linters:
          - revive

formatters:
  enable:
    - gofmt
    - goimports

There are a few principles embedded in this example.

Start with default: none

Explicitly enabling linters makes the configuration readable months later. A maintainer should be able to answer, "Why is this linter enabled here?" without reverse-engineering defaults from a tool version that may already be outdated.

Turn on Tests Deliberately

Linting test code is worth it. Test files are still production assets in the sense that they protect production behavior. At the same time, some rules can be slightly too strict in tests, especially around intentionally ignored cleanup errors or verbose table-driven test fixtures. Excluding very specific cases is better than excluding tests wholesale.

Keep Exclusions Surgical

The exclusions section should be narrow and justified. "Ignore this whole directory because the output is noisy" is usually a smell. "Ignore errcheck on one known cleanup pattern in test files" is usually reasonable.

Do Not Forget the Built-In Formatters

Recent golangci-lint versions can also run formatters such as gofmt and goimports. That can be a very practical choice for maintainers because it keeps linting and formatting policy in one place instead of splitting it across multiple tools and CI jobs.

If you already run gofmt or goimports separately and that works well, there is no urgent need to consolidate. But for a new repository, letting golangci-lint own both checks can be a clean default.

Suppressions: Use //nolint Like a Scalpel

Every mature Go project ends up needing the occasional suppression. The goal is not to ban them. The goal is to keep them rare, precise, and understandable.

Good //nolint

//nolint:errcheck // Best-effort cleanup in a deferred close.
defer file.Close()
//nolint:revive // This name mirrors the upstream JSON field and must remain stable.
type APIResponse struct {
    UserID string `json:"userId"`
}

These are good suppressions because they do three things:

  • Name the specific linter
  • Stay attached to the code they justify
  • Explain the reason in plain language

Bad //nolint

//nolint
doThing()

This suppresses everything and explains nothing. It tells future maintainers, "something here was inconvenient once." That is not enough context for a long-lived repository.

Prefer the Narrowest Possible Suppression

Use //nolint:errcheck instead of plain //nolint. If more than one linter needs to be suppressed, list them explicitly and leave a reason.

Local Workflow: Fast Feedback First

The most effective linting workflow is the one developers actually run before pushing. That usually means short, memorable commands and a workflow that does not punish iteration.

At minimum, give the repository a single lint target:

lint:
    golangci-lint run

If you use Task, the equivalent is just as straightforward:

---
# Taskfile
version: "3"

tasks:
  lint:
    desc: Run golangci-lint
    cmds:
      - golangci-lint run

The version: "3" line here is the Taskfile schema version, not a golangci-lint configuration version. In this post, version: "2" refers to .golangci.yml, while version: "3" refers to Taskfile.yml.

That one target becomes the contract for local development, editor integration, pre-commit, and CI.

CI Strategy for Open Source Projects

Maintainers have a slightly different problem than individual developers. You are not only trying to lint code. You are trying to evolve code quality without making the project hostile to drive-by contributors.

On a New or Clean Repository

If the codebase is already in good shape, fail CI on all lint issues:

golangci-lint run

Simple is best when you can afford it.

On an Existing Repository with Historical Debt

If enabling a strong linter set would surface hundreds of pre-existing issues, do not block every contributor on day one. Gate only newly introduced problems first:

golangci-lint run --new-from-rev=origin/main

That lets maintainers raise the quality bar immediately without demanding a giant cleanup branch before any other work can land. Over time, you can reduce the backlog and eventually switch CI to a full run.

GitHub Actions Example

---
#.golangci.yml
name: Lint

on:
  push:
    branches:
      - main
    paths-ignore:
      - ".github/**"
      - "docs/**"
      - "scripts/**"
      - "README.md"
  pull_request:
    paths-ignore:
      - ".github/**"
      - "docs/**"
      - "scripts/**"
      - "README.md"
  schedule:
    - cron: "30 00 * * 1"

permissions:
  contents: read

jobs:
  golangci:
    name: golangci-lint
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - name: Setup Go
        uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
        with:
          go-version-file: go.mod
          cache: true
      - name: Download Modules
        run: go mod download
      - name: Verify Build Context
        run: go build -v .
      - name: Run Linters
        uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
        with:
          version: v2.11.3
          args: --timeout=5m

Pinning the action by commit hash matters for the same reason it matters everywhere else in GitHub Actions: it ensures the code executing in CI is the code you reviewed.

The action's version input supports either an explicit tool version such as v2.11.3 or latest. The action documentation allows both, but I recommend pinning an explicit version in CI so tool upgrades happen intentionally and are reviewed like any other dependency change.

If you want the upstream docs and source, see the official golangci/golangci-lint-action repository for the GitHub Action and the main golangci/golangci-lint repository for the tool itself.

The setup and preflight steps matter too. Running go mod download and a quick go build before linting helps surface dependency and package-loading problems as build-context issues instead of burying them inside linter output. On larger repositories, using paths-ignore and a periodic scheduled run also keeps CI efficient without letting linting silently drift.

Opinionated Recommendations

If I were setting up golangci-lint on a fresh open source Go repository today, I would do the following:

  1. Start with govet, staticcheck, errcheck, ineffassign, unused, gosimple, and revive.
  2. Keep the configuration explicit with default: none.
  3. Require explanations for every //nolint.
  4. Use --new-from-rev=origin/main if the repository has historical debt.
  5. Pin an explicit golangci-lint version in CI instead of floating on latest.
  6. Warm the Go build context in CI with go mod download and a quick build step.
  7. Keep linting in the same make, task, or CI contract contributors already use.
  8. Review linter additions like policy changes, not like casual tool tweaks.

The key idea is that linting is governance. A repository's lint configuration is part of its maintainer contract with contributors. It defines what "acceptable" means in code review before a reviewer even types a comment.

That is probably the biggest lesson I took away from maintaining Terraform providers and Packer plugins in Go. The job is not to enable more linters than the next project. The job is to make the lint output trustworthy enough that contributors and maintainers both believe it is worth fixing.

Reference

Everyday Commands

Command What It Does When to Use It
golangci-lint run Run enabled linters against the current module Default local and CI check
golangci-lint run ./... Run against all packages explicitly Useful when you want the package pattern to be obvious in scripts
golangci-lint run --fix Auto-fix supported issues Good for local cleanup before commit
golangci-lint run --timeout=5m Override the analysis timeout Useful in CI or larger module graphs
golangci-lint run --tests=false Skip test files Helpful for troubleshooting, but not my default recommendation.
golangci-lint run --new-from-rev=origin/main Report only issues introduced since a base revision Best for repositories adopting linting gradually
golangci-lint run -E revive -E gocritic Enable additional linters for one run Great for experimentation before changing repo policy
golangci-lint run -D errcheck Disable a linter for one run Useful when debugging or isolating noisy output
golangci-lint linters List available linters and status Use before editing config or documenting repo standards
golangci-lint version Print the installed version Useful in CI debugging and support requests
golangci-lint cache clean Clear cached analysis data Helpful after environment changes or odd cache behavior
lint:
    golangci-lint run

lint-new:
    golangci-lint run --new-from-rev=origin/main

lint-fix:
    golangci-lint run --fix
---
# Taskfile
version: "3"

tasks:
  lint:
    cmds:
      - golangci-lint run

  lint:new:
    cmds:
      - golangci-lint run --new-from-rev=origin/main

  lint:fix:
    cmds:
      - golangci-lint run --fix

Common Configuration Syntax

Configuration File Names

golangci-lint supports multiple configuration file names and formats. In practice, the most common choice is .golangci.yml, using the current version: "2" schema.

File Notes
.golangci.yml Most common and easiest to read in Go projects
.golangci.yaml Equivalent to .yml
.golangci.toml Useful if the repository prefers TOML
.golangci.json Rare, but supported in many setups

Common YAML Structure

---
# .golangci.yml
version: "2"

linters:
  default: none
  enable:
    - govet
    - staticcheck
    - errcheck
    - revive
  settings:
    revive:
      rules:
        - name: exported
  exclusions:
    rules:
      - path: _test\\.go
        linters:
          - errcheck

formatters:
  enable:
    - gofmt

//nolint Syntax Reference

Syntax Meaning Recommendation
//nolint Suppress all linters for the attached code Avoid unless there is no better option
//nolint:errcheck Suppress one specific linter Preferred over blanket suppression
//nolint:errcheck,revive Suppress multiple named linters Acceptable when each one is actually needed
//nolint:errcheck // reason Suppress with an explanation Best practice

Examples:

//nolint:errcheck // Deferred close is best effort only.
defer file.Close()
//nolint:revive // External API naming is fixed by contract.
type APIResponse struct {
    UserID string `json:"userId"`
}
Profile Linters Best for
Minimal govet, staticcheck, errcheck, ineffassign Small projects or teams introducing linting gently
Practical default govet, staticcheck, errcheck, ineffassign, unused, gosimple, revive Most open source libraries and services
Stricter maintainer profile Practical default plus gocritic, misspell, stylecheck, unconvert Mature repositories with active review discipline

Troubleshooting Patterns

Symptom Likely Cause First Thing to Check
typecheck failures Package loading or compile issues go build ./...
Missing export data Broken module graph or version mismatch go mod tidy and Go version alignment
Slow CI runs Cold cache, too many analyzers, large module graph Timeout, caching, and enabled linter count
Too many false positives Overly broad linter set or weak exclusions Start from a smaller baseline
//nolint everywhere Policy too strict or poorly tuned Review the enabled linters, not just the suppressions

If you remember only one thing, make it this: golangci-lint is most useful when it is treated as a carefully curated repository standard, not as a giant switch that turns on every available opinion about Go code.