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:
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:
- It discovers the packages to analyze.
- It loads package metadata and type information through the Go toolchain.
- It runs the enabled analyzers.
- 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:
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 flaggingstaticcheck: finds correctness issues and non-idiomatic code with a strong signal-to-noise ratioerrcheck: catches ignored errors, which are one of the most common classes of Go bugsineffassign: finds assignments whose values are never usedunused: catches dead code and unused declarationsgosimple: points out simpler, more idiomatic equivalentsrevive: configurable style and maintainability checks, and the practical replacement for deprecatedgolint
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 carefullygosec: worth considering on public-facing projects and provider or plugin codebases, but it needs careful tuning to avoid normalizing false positivesmisspell: low-friction polish for comments, docs, and user-facing stringsunconvert: catches redundant conversionsunparam: useful in libraries and mature codebases, but noisy during active refactoringstylecheck: 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: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¶
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:
If you use Task, the equivalent is just as straightforward:
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:
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:
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:
- Start with
govet,staticcheck,errcheck,ineffassign,unused,gosimple, andrevive. - Keep the configuration explicit with
default: none. - Require explanations for every
//nolint. - Use
--new-from-rev=origin/mainif the repository has historical debt. - Pin an explicit
golangci-lintversion in CI instead of floating onlatest. - Warm the Go build context in CI with
go mod downloadand a quick build step. - Keep linting in the same
make,task, or CI contract contributors already use. - 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¶
Common and Recommended golangci-lint Commands¶
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 |
Recommended Maintainer Aliases and Targets¶
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:revive // External API naming is fixed by contract.
type APIResponse struct {
UserID string `json:"userId"`
}
Recommended Starter Linter Sets¶
| 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.