The formatting war
You finish a feature. The tests pass. You push to the repository. The CI pipeline fails because someone used two spaces instead of four, or because a function returns an error that nobody checks. The team spends twenty minutes arguing about indentation while the actual logic sits untouched. This happens in every Go project that skips automated style enforcement.
Linting solves that friction. It turns subjective style debates into objective, machine-checked rules. Instead of humans policing semicolons and variable names, a tool runs a standardized checklist on every commit. You get consistent code without the meetings.
What the tool actually does
golangci-lint is not a single linter. It is a runner that orchestrates dozens of independent Go analysis tools. Think of it as a quality control inspector carrying a master clipboard. One checklist catches unused variables. Another catches memory leaks. Another enforces formatting. Another checks for security anti-patterns. Instead of installing and running ten separate binaries, you run one command that fires them all in parallel.
The tool reads your go.mod to understand dependencies, parses your source files into an Abstract Syntax Tree, and feeds that tree to each enabled checker. It caches results between runs so it only analyzes changed files. It outputs a unified report that points to exact line numbers and suggests fixes.
Linters don't write code. They catch the mistakes you make when you're tired.
Your first configuration
Here's the simplest configuration file: spawn one, enable the core linters, add a formatter, and run it.
# version 2 is the current schema. It separates linters from formatters.
version: "2"
# linters group the logic and style checkers.
linters:
# start with none so you control exactly what runs.
default: none
enable:
# govet catches subtle logical bugs like unreachable code.
- govet
# ineffassign finds assignments that never get read.
- ineffassign
# formatters handle indentation and whitespace.
formatters:
enable:
# gofmt is the official baseline. Every Go project uses it.
- gofmt
# gofumpt adds stricter rules like automatic import grouping.
- gofumpt
settings:
gofmt:
# simplify rewrites redundant expressions like map[k] = v to map[k] = v.
simplify: true
gofumpt:
# extra-rules enforces community conventions like consistent spacing.
extra-rules: true
Save this as .golangci.yml in your project root. Run golangci-lint run ./... to check every package. The ./... tells the tool to recurse through all subdirectories.
The tool will scan your files, apply the rules, and print a list of violations. Fix them, run it again, and watch the output shrink.
Linters are cheap. Formatters are automatic. Let the machine handle the whitespace.
How the pipeline runs
When you execute the command, the tool first loads your module graph. It resolves imports, checks Go version compatibility, and builds a dependency tree. Next, it parses each .go file into an AST. The AST is a structured representation of your code that strips away formatting and exposes the actual logic.
The runner then distributes the AST to each enabled linter. govet walks the tree looking for control flow anomalies. ineffassign tracks variable lifetimes to spot dead writes. The formatters run last, comparing your file against their canonical output. If a file differs, the tool flags it as a formatting violation.
Results are cached in a hidden directory. On the second run, the tool skips unchanged files and only reanalyzes what you touched. This keeps CI times low even on large codebases.
The AST is the map. The linters are the traffic cops.
Real-world project setup
Production projects need more than the defaults. You want to catch unchecked errors, verify HTTP response bodies are closed, and enforce naming conventions. Here's a configuration that covers the common bases without becoming noisy.
version: "2"
linters:
default: none
enable:
# govet and ineffassign remain the foundation.
- govet
- ineffassign
# staticcheck catches complex bugs and suggests modern idioms.
- staticcheck
# revive enforces style rules like max function length.
- revive
# errcheck ensures you handle errors instead of dropping them.
- errcheck
# bodyclose verifies HTTP responses are closed to prevent leaks.
- bodyclose
formatters:
enable:
- gofmt
- gofumpt
settings:
gofmt:
simplify: true
gofumpt:
extra-rules: true
# issues controls what gets reported and what gets ignored.
issues:
# exclude test files from strict formatting rules.
exclude-rules:
- path: _test\.go
linters:
- gofmt
- gofumpt
The issues section prevents the tool from flagging generated code or test files that intentionally break style rules. You can also add exclude-use-default: true to disable the built-in ignore list and take full control.
Run the linter locally before pushing. Use golangci-lint run --fix to automatically resolve formatting violations. The --fix flag rewrites files in place, saving you manual edits.
A noisy linter is worse than no linter. Silence the false positives or disable the rule.
Common traps and compiler noise
New teams often treat the linter as a substitute for the compiler. They are not the same. The compiler enforces syntax and type safety. It rejects programs that cannot run. Linters enforce style, potential bugs, and anti-patterns. They reject programs that might run poorly.
If you forget to capture a loop variable, the compiler rejects the program with loop variable i captured by func literal. If you pass the wrong type to a function, you get cannot use x (untyped int constant) as string value in argument. These are hard errors. The program will not compile.
Linters produce warnings. errcheck will flag fmt.Println("hello") with Error return value is not checked. You can fix it by assigning the result or using the underscore to discard it intentionally. result, _ := fmt.Println("hello") tells the tool you considered the error and chose to drop it. Use the underscore sparingly. Dropping errors silently is how production outages start.
Another common trap is over-configuring. Enabling every linter in the registry creates a wall of warnings. Developers start ignoring the output entirely. Start with five to seven checkers. Add more as the team matures. The convention in Go is to keep the happy path clean and make the unhappy path visible. The verbose if err != nil { return err } pattern exists for a reason. Linters enforce that visibility.
Goroutine leaks happen when a background task waits on a channel that never closes. bodyclose and staticcheck catch resource leaks before they hit production. Always design a cancellation path. Context should flow through every long-lived call site.
The worst lint warning is the one nobody reads.
When to reach for golangci-lint
Use golangci-lint when you need a single command to run multiple style and logic checkers across a growing codebase. Use go vet when you want a fast, zero-configuration check for obvious control flow bugs without external dependencies. Use staticcheck as a standalone tool when you only need deep semantic analysis and want to skip formatting enforcement. Use manual code review when you need to evaluate architecture, naming decisions, and business logic that machines cannot judge.
Automate the boring checks. Save human review for architecture.