Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 076 / 100

golangci-lint setup: a practical guide for developers

Catch bugs and style issues.

Expona Admin6 min read

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:

go
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:

go
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:

go
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:

go
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:

go
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:

go
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
"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:

go
- 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:

go
#!/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:

  1. False positives you don't understand yet. Ask a senior team member. Often it's a valid concern you didn't see.
  2. 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.
  3. 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:

go
"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:

go
// 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 //nolint is 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. staticcheck flags 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, and gofmt. 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. //nolint is 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles