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:
- 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.
- Improved Configuration Structure: The v2 schema is arguably cleaner and more organized, separating concerns like linter enablement, settings, exclusions, and formatting rules more explicitly.
- Better Maintainability: A clearer configuration is easier for the whole team to understand and modify.
- 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 toenable
(ordisable
). 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 likegofmt
,goimports
, andgci
.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()
witht.Context()
throughout our test files. This was flagged by the newly enabledcontextcheck
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.