Level Up Your Go Linting: Migrating to GolangCI-Lint v2 Configuration and Beyond

2025-05-03

Level Up Your Go Linting: Migrating to GolangCI-Lint v2 Configuration and Beyond

Ah, linting. That trusty sidekick that keeps our Go code from straying into the Wild West of inconsistent styles, potential bugs, and hidden performance traps. If you're working on any non-trivial Go project, chances are you're using GolangCI-Lint, the incredibly popular and powerful meta-linter. It's fantastic, but like any tool, it evolves.

Recently, while working on updating dependencies for a Kubernetes provider project, we took the opportunity to modernize our GolangCI-Lint setup. This involved migrating to its version: "2" configuration schema and refining our linter rules. Why bother? Because staying current with your tooling isn't just about chasing shiny new things; it's about leveraging better static analysis, improving configuration clarity, and ultimately, writing more robust and maintainable Go code.

If your .golangci.yml hasn't seen much love lately, or if you're curious about the benefits of the newer config structure, you're in the right place. Let's dive into why you should consider this upgrade and how to do it, based on real-world experience.

Why Upgrade Your Linter Config? The Perks of Staying Fresh

Before we get into the nitty-gritty, let's quickly touch on the motivations:

  1. Access to Newer Linters & Checks: The Go ecosystem is constantly improving, and new static analysis techniques emerge. Newer GolangCI-Lint versions bundle these, helping you catch more potential issues.
  2. Improved Configuration Structure: The v2 schema is arguably cleaner and more organized, separating concerns like linter enablement, settings, exclusions, and formatting rules more explicitly.
  3. Better Maintainability: A clearer configuration is easier for the whole team to understand and modify.
  4. Enforcing Best Practices: Updated linters often incorporate checks for modern Go best practices (as we saw with context handling in tests).

The Big Shift: Understanding the Version 2 Schema

The most immediate change is adding version: "2" at the top of your .golangci.yml. This isn't just a label; it signals to GolangCI-Lint to interpret the configuration using the newer, more structured layout.

Here's a conceptual breakdown of the key structural differences:

  • Before (Legacy/v1-ish): Settings, enabled linters, disabled linters, and exclusions were often mixed at the top level or under a general linters-settings key. Formatting tools (gofmt, goimports) were treated just like other linters.
  • After (v2):
    • linters: Explicitly lists linters to enable (or disable). Presets are less common.
    • settings: Contains configuration specific to individual linters, nested under the linter's name.
    • exclusions: Handles rules for ignoring issues, structured more clearly (e.g., generated, rules, paths).
    • formatters: A dedicated section for code formatters like gofmt, goimports, and gci.
    • output: Defines output formats and options.
    • run: General runner options like timeout and exit codes.

This separation makes the configuration much more logical and less prone to clutter.

Dissecting the New .golangci.yml: Key Sections Explained

Let's look at the core sections you'll interact with in a v2 configuration, using examples inspired by our recent refactor:

1. linters: - Enabling Your Checks

This section is straightforward. You list the linters you want to run. It's generally recommended to explicitly enable the linters you need rather than relying heavily on presets or broad disables.

# .golangci.yml
version: "2"

# ... other sections

linters:
  enable:
    # Core Go checks
    - govet
    - ineffassign
    - unused

    # Style & Complexity
    - cyclop
    - goconst
    - gocritic
    - gci # Note: GCI might be better in 'formatters' now
    - whitespace

    # Bug Prevention
    - bodyclose
    - contextcheck # Crucial for test contexts!
    - copyloopvar # Go 1.22+ loop variable check
    - errorlint
    - nilerr
    - rowserrcheck
    - sqlclosecheck
    - unparam

    # Security
    - gosec

    # Test Helpers
    - testifylint
    - thelper
    - paralleltest # Encourages t.Parallel()

    # ... add other linters you value

2. settings: - Tuning Your Linters

This is where you configure the behavior of specific enabled linters. Each linter's settings are nested under its name.

# .golangci.yml
# ... version and linters sections

linters:
  settings:
    # Example: Set max complexity for cyclop
    cyclop:
      max-complexity: 15

    # Example: Configure gocritic checks
    gocritic:
      enabled-tags:
        - diagnostic
        - experimental
        - opinionated
        - performance
        - style
      settings: # Settings specific *to* gocritic
        captLocal:
          paramsOnly: true
        rangeValCopy:
          sizeThreshold: 32 # Warn on large copies in range loops

    # Example: Configure dependency restrictions
    depguard:
      rules:
        main:
          files:
            - "$all"
            - '!$test' # Don't apply to test files
          deny:
            - pkg: reflect
              desc: Reflection is often unclear; consider alternatives.
            - pkg: "io/ioutil" # Deprecated since Go 1.16
              desc: "Use 'os' or 'io' package instead."

    # Example: Configure variable name length
    varnamelen:
      min-name-length: 2 # Require variable names >= 2 chars

    # ... other linter settings

3. exclusions: - Quieting the Noise (Carefully!)

No codebase is perfect, and sometimes you need to ignore specific warnings in specific places. The v2 schema provides a structured way to do this.

# .golangci.yml
# ... other sections

linters:
  exclusions:
    # Ignore warnings in auto-generated files more leniently
    generated: lax

    # Define specific rules for ignoring issues
    rules:
      # Example: Relax certain checks in test files
      - linters:
          - copyloopvar
          - dupl # Duplicate code might be okay in tests
          - errcheck
          - gocyclo
          - gosec
          - maintidx
          - unparam
        path: _test(ing)?\.go # Regex for test files

      # Example: Ignore specific gocritic checks in test files by text match
      - linters:
          - gocritic
        path: _test\.go
        text: (unnamedResult|exitAfterDefer) # Match specific warning text

      # Example: Ignore specific gosec warnings globally by code
      - linters:
          - gosec
        text: 'G101:' # Potential hardcoded credentials (often false positives with K8s Secrets)

    # Ignore entire paths (useful for vendored code, third-party libs)
    paths:
      - zz_generated\..+\.go$ # Common pattern for generated code
      - third_party/
      - internal/vendor/ # If you still vendor locally

issues:
  # Prevent golangci-lint from exiting with an error code on the first run
  # if there are too many issues. Set max counts to 0 for unlimited.
  max-issues-per-linter: 0
  max-same-issues: 0
  # Exclude default paths (like vendor/) - often false if you specify in paths above
  exclude-use-default: false
  # Only analyze new code changes (useful for large legacy projects)
  new: false

4. formatters: - Dedicated Formatting Rules

This is a key improvement in v2. Formatters like gofmt, goimports, and gci (Go Code Importer) now live here, separating them logically from linters that find issues.

# .golangci.yml
# ... other sections

formatters:
  enable:
    - gci
    - gofmt
    - goimports
  settings:
    gci:
      sections:
        - standard # Standard library imports
        - default # Other third-party imports
        - blank # Blank line separator
        - dot # Dot imports
        - prefix(github.com/your-org/your-repo) # Your project's packages
    gofmt:
      simplify: true # Use gofmt -s
    goimports:
      local-prefixes:
        - github.com/your-org/your-repo # Help goimports identify local packages
  exclusions: # Exclusions specific to formatters
    generated: lax
    paths:
      - zz_generated\..+\.go$
      # You might need to exclude specific files where auto-formatting causes issues
      # - path/to/problematic/file.go

Real-World Example: Enabling contextcheck

In our project update, enabling the contextcheck linter was particularly valuable. It flagged numerous instances in our tests where we were passing context.Background() instead of the test-specific t.Context().

  • Why it matters: Using t.Context() allows tests to respect timeouts and cancellations managed by the test runner (go test -timeout), making the test suite more robust and preventing potential hangs.
  • The Fix: We systematically replaced context.Background() with t.Context() throughout our test files. This was flagged by the newly enabled contextcheck linter after updating the config.

Integrating with Your Workflow

Don't forget to update how you run the linter:

Makefile Integration:

Using a variable makes version bumps trivial.

# Makefile

# Default version, can be overridden
GOLANGCI_LINT_VERSION ?= v2.1.5 # Or your desired version

.PHONY: lint
lint: ## Run lint checks against the codebase.
	@echo "--- Linting code..."
	docker run --rm -w /workdir -v $(PWD):/workdir golangci/golangci-lint:$(GOLANGCI_LINT_VERSION) golangci-lint run -c .golangci.yml --fix

CI/CD (GitHub Actions Example):

Update the action version and ensure it picks up your new config.

# .github/workflows/go-analyze.yml
# ... workflow setup

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.24' # Match your project's Go version
          cache: false

      - name: lint
        # Use a recent version of the action compatible with v2 configs
        uses: golangci/golangci-lint-action@v7
        with:
          # Optional: Specify version, or let it use the latest stable
          # version: v1.59
          # The action automatically finds .golangci.yml
          args: --fix # Apply fixes if possible

Practical Tips & Gotchas

  • Iterate: Don't feel obligated to enable every possible linter at once, especially on a legacy codebase. Start with a core set, fix the reported issues, then gradually enable more.
  • Run Locally First: Before pushing config changes, run golangci-lint run locally to see the impact. --fix can help automate simple changes.
  • Noise Reduction: Be prepared for initial noise if you enable many new linters. Use the exclusions section judiciously to silence warnings that are truly acceptable in specific contexts, but avoid overly broad exclusions.
  • Team Buy-in: Discuss significant linting changes with your team to ensure everyone understands the rules and benefits.

Conclusion: Cleaner Code Awaits!

Migrating your GolangCI-Lint configuration to the version: "2" schema and refining your linter set might seem like a chore, but the payoff is significant. You gain a clearer, more maintainable configuration file, access to the latest static analysis checks, and a powerful tool for enforcing code quality and best practices across your team.

By separating concerns and providing more structured settings and exclusions, the v2 config helps you wield the full power of GolangCI-Lint more effectively. So, take a look at your .golangci.yml, consider the upgrade, and level up your Go linting game! Your future self (and your teammates) will thank you.