Introduction
Linting is one of those tasks that feels optional until your team catches a production bug that a linter would have caught in seconds. In Go, golangci-lint is the de facto standard for static analysis, combining dozens of linters into a single tool that integrates seamlessly with CI pipelines and editors. This guide walks you through golangci-lint setup: a practical guide for developers, focusing on configuration that catches real issues without drowning your team in false positives.
A well-tuned linter catches bugs early, enforces consistency, and saves code review time. The key is enabling the right set of linters—not all of them—and making sure your team actually fixes warnings instead of ignoring or disabling them.
Why golangci-lint matters in production
We've all inherited codebases where golangci-lint disable-all was the first line of the config. That's a red flag. Linters catch real bugs: unhandled errors, unused variables that hint at logic errors, and race conditions that only surface under load. When your API processes millions of transactions or serves critical SaaS workflows, these catches matter.
golangci-lint runs multiple linters in parallel, which means the overhead is low compared to running them individually. On modern hardware, even large codebases lint in under a second.
Enable a focused set of linters
The biggest mistake teams make is enabling every linter or disabling all of them. Instead, start with a focused baseline and expand thoughtfully.
Here's a practical starting configuration for a production API:
version: "1.59"
run:
timeout: 5m
allow-parallel-runners: true
linters:
enable:
- errcheck
- staticcheck
- govet
- ineffassign
- unused
- gofmt
- goimports
disable-all: false
fast: false
issues:
exclude-rules:
- path: _test\.go
linters:
- errcheck
- linters:
- staticcheck
text: "SA1019"This configuration enables six core linters that catch the most common issues without false positives. Let's walk through why each matters.
errcheck: catching unhandled errors
Unhandled errors are the leading cause of silent failures in Go services. errcheck flags functions whose error returns are ignored.
Example: you write a deferred cleanup that ignores errors:
func processPayment(file *os.File) error {
defer file.Close() // errcheck flags this
// file.Close() can fail, silently losing data
return nil
}errcheck catches this immediately. The fix is explicit:
func processPayment(file *os.File) error {
defer func() {
if err := file.Close(); err != nil {
log.Printf("close error: %v", err)
}
}()
return nil
}In tests, ignored errors are often fine—you're testing the happy path, not cleanup. That's why we exclude errcheck for *_test.go files.
staticcheck: subtle logic bugs
staticcheck is the heavy hitter. It catches incorrect API usage, impossible conditions, and dead code that no human reviewer would notice in a large diff.
Real example: a goroutine leak from our ride-hailing service:
func trackDriverLocation(ctx context.Context, driverID string) {
go func() {
ticker := time.NewTicker(5 * time.Second)
for range ticker.C { // staticcheck flags this
select {
case <-ctx.Done():
return
default:
// update location
}
}
}()
}
The ticker never stops, it just gets garbage collected. staticcheck catches this. The fix:
func trackDriverLocation(ctx context.Context, driverID string) {
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
select {
case <-ctx.Done():
return
default:
// update location
}
}
}()
}
govet: type and pointer errors
govet, integrated into golangci-lint, catches incorrect pointer usage, misaligned struct fields, and type mismatches that can lurk for months.
Example from a CRM system:
type Customer struct {
ID int64
Name string
Tags []string
}
func logCustomer(c Customer) {
fmt.Printf("Customer: %+v\n", &c) // govet warns
// passing by pointer when value is expected
}
Minor, but it's a signal that the code isn't thinking about ownership. govet nudges you toward clarity.
Run in CI and editors
Linting only matters if it runs before code reaches production. We run it in two places: developers' editors and CI pipelines.
Editor integration for immediate feedback
Most teams use VS Code. Install the Go extension and configure golangci-lint as the linter:
"go.lintTool": "golangci-lint",
"go.lintOnSave": "package",
"go.lintFlags": [
"--fast",
"--deadline=30s"
]This gives developers feedback while coding. Errors appear inline, breaking the flow. This is where linting earns its keep—you see issues before commit.
CI pipeline enforcement
In your GitHub Actions workflow or GitLab CI, add a linting step:
- name: Lint code
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout=5m
Make this required before merge. No exceptions. If a warning slips through in the editor, CI catches it.
Pre-commit hooks
For teams wanting the tightest feedback loop, use pre-commit hooks:
#!/bin/bash
golangci-lint run ./...
if [ $? -ne 0 ]; then
echo "Linting failed. Fix issues before commit."
exit 1
fi
Add this to .git/hooks/pre-commit. Developers can't commit bad code, period.
Fix warnings instead of disabling them
This is the hardest part. When a warning appears, the instinct is to silence it with a //nolint comment. Resist that.
We've seen three categories of "noisy" warnings in production teams:
- False positives you don't understand yet. Ask a senior team member. Often it's a valid concern you didn't see.
- Legitimate exceptions. Sometimes you need to disable a check for one line, e.g., deliberately using an unsafe operation for performance. That's fine—document it.
- Configuration issues. The linter is too strict. Adjust the config instead of disabling the linter.
Example: your team complains that goimports reformats imports constantly. Don't disable it. Instead, configure your editor and pre-commit to run goimports automatically:
"editor.formatOnSave": true,
"[go]": {
"editor.defaultFormatter": "golang.go",
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
}
Now imports are always correct, and developers never fight the linter.
When to use //nolint
Use //nolint only when you've decided the warning doesn't apply:
// intentional: we retry with exponential backoff
// before returning this error to avoid thundering herd
//nolint:errcheck
conn.Close()
resp, err := retryRequest(ctx, backoffSchedule)
return err
Include a comment explaining why. This helps future maintainers understand the decision.
Common mistakes
- Disabling linters instead of fixing code. Every
//nolintis technical debt. Fix the underlying issue. - Enabling too many linters at once. Cyclomatic complexity, inefficiency checks, naming conventions—these create noise. Stick to correctness and safety.
- Not running linters locally before pushing. CI should never be the first place your code is linted. Use editor integration.
- Ignoring deprecation warnings.
staticcheckflags deprecated APIs. They exist for a reason. Migrate proactively instead of silencing. - Slow CI linting. If linting takes 10 minutes, developers will skip it. Optimize: use
--deadline, run linters in parallel, cache results.
Key takeaways
- Start with six core linters:
errcheck,staticcheck,govet,ineffassign,unused, andgofmt. Add others only when you have a specific need. - Run linting in editors for immediate feedback and in CI for enforcement. Pre-commit hooks add a third layer.
- Fix warnings, don't disable them.
//nolintis for exceptions, not escape hatches. - Invest time tuning the config once. A well-configured linter becomes invisible—developers don't fight it, and it catches real bugs.
- Treat linting like testing. It's not optional, and it pays dividends over months of development.






Comments
Loading comments…