staticcheck & golangci-lint in CI
staticcheck is a high-signal analyzer for bugs and API misuse.
golangci-lint runs staticcheck and dozens of other checks behind one config file, making it the usual CI entry point for Go lint policy.
Summary
Local editors give fast feedback; CI enforces the same rules for every contributor and bot.
Start with go vet and staticcheck, then layer golangci-lint with a committed .golangci.yml.
Adopt new checks incrementally on legacy repos so teams fix findings instead of disabling linters permanently.
Recipe
Quick-reference recipe card - copy-paste ready.
go install honnef.co/go/tools/cmd/staticcheck@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
staticcheck ./...
golangci-lint run ./...Minimal .golangci.yml:
run:
timeout: 5m
linters:
enable:
- govet
- staticcheck
- ineffassign
- unused
issues:
max-issues-per-linter: 0When to reach for this:
- Standardizing lint across microservices in one org.
- Turning review comments ("check error return") into automated failures.
- Raising the bar after
go vetwith correctness checks (staticcheck SA* rules). - Gating releases on zero linter issues.
Working Example
Problematic handler:
// example.com/demo/internal/api/handler.go
package api
import (
"fmt"
"net/http"
)
func Health(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "ok") // missing error check
defer r.Body.Close() // Body may be nil on GET
}staticcheck locally:
staticcheck ./internal/api/...Typical findings include mishandled errors and suspicious constructs.
Wire CI with golangci-lint:
name: lint
on: [push, pull_request]
jobs:
golangci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26.x'
- run: go mod download
- uses: golangci/golangci-lint-action@v6
with:
version: latest
args: ./...Fixed handler:
func Health(w http.ResponseWriter, r *http.Request) {
if _, err := fmt.Fprintf(w, "ok"); err != nil {
http.Error(w, "write failed", http.StatusInternalServerError)
return
}
if r.Body != nil {
defer r.Body.Close()
}
}What this demonstrates:
- staticcheck flags real correctness/style issues beyond formatting.
- golangci-lint-action runs the same config contributors use locally.
- Fixes are ordinary Go - linters encode policies you would ask for in review.
Deep Dive
How It Works
- Analyzers use
go/analysis(directly or via wrappers) to inspect typed ASTs. - golangci-lint loads enabled linters, runs them in parallel per package, and aggregates issues.
- Exit code non-zero when issues exceed configured limits (
max-issues-per-linter).
staticcheck Highlights
| Category | Examples |
|---|---|
| Correctness | Impossible conditions, dubious comparisons, context misuse |
| Performance | Suboptimal append patterns, redundant conversions |
| Style | Deprecated API usage with suggested replacements |
Run staticcheck -explain SA1006 (example code) to read rule docs.
golangci-lint Configuration Knobs
| Key | Purpose |
|---|---|
linters.enable / disable | Curate the bundle |
linters-settings | Per-linter config (e.g., gosec severity) |
run.build-tags | Match tagged builds in CI |
issues.exclude-rules | Narrow suppressions with path and text matchers |
issues.new-from-rev | Only fail on issues introduced after a baseline commit |
Baseline Adoption on Brownfield Repos
- Run
golangci-lint run ./... > baseline.txtand fix critical packages first. - Enable
issues.new-from-rev: origin/mainso legacy debt does not block every PR. - Burn down baseline in dedicated chores; tighten to zero over sprints.
- Avoid blanket
nolintcomments - require ticket IDs when exceptions are unavoidable.
Go Notes
run:
go: "1.26"
build-tags:
- integrationAlign tags with test jobs that use -tags=integration.
Gotchas
- Different golangci-lint versions locally vs CI - Rule sets drift. Fix: Pin version in CI action and document install command for devs.
- Over-broad
nolintdirectives -//nolint:allhides future bugs. Fix: Name specific linters and justify in comment. - Running linters without module download - False negatives or load errors. Fix:
go mod downloadbefore lint job. - Excluding test files entirely - Tests hide racey patterns. Fix: Lint tests too; use
issues.exclude-rulesonly for known noisy paths. - max-issues zero on day one - Teams disable CI. Fix: Use
new-from-revor temporary warning mode. - Ignoring staticcheck deprecations - Future Go releases remove symbols. Fix: Treat SA1019 (deprecated) as errors in libraries.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
go vet only | Tiny scripts, teaching repos | Production services needing deeper checks |
| staticcheck alone | Single linter, minimal config | You want gosec, misspell, and import lint in one run |
Custom go/analysis driver | One bespoke rule | Standard community coverage (use golangci-lint) |
| Review-only policy | Pre-lint culture | Scales poorly past a handful of engineers |
FAQs
Should I run staticcheck separately if golangci-lint includes it?
Optional - golangci-lint is enough in CI if staticcheck is enabled.
Some teams run staticcheck alone in pre-commit for speed.
How do I silence one false positive?
Use a targeted issues.exclude-rules entry with path and linter keys.
Prefer fixing the code if the pattern is risky elsewhere.
Does golangci-lint format code?
It reports issues; formatting is gofmt/gofumpt.
Enable gofmt linter to fail on unformatted files.
What timeout should CI use?
Large monorepos need 5-10 minutes.
Start at 5m; increase if jobs cancel mid-run.
Can linters run on generated code?
Exclude *.pb.go and vendor/ via run.skip-dirs or issues.exclude-rules.
Do not lint generated output you cannot change.
How does this relate to gopls diagnostics?
gopls embeds a subset for editor speed.
CI config is authoritative for merge requirements.
Should tests use build tags in lint?
Yes if production code uses tags - mirror CI test tags in run.build-tags.
Is golangci-lint mandatory for Go?
No, but it is the de facto standard orchestrator.
go vet plus staticcheck is a lighter minimum.
How do I lint multiple modules?
Run from each module root or use a meta-repo script looping go.mod paths.
go.work does not replace per-module CI jobs automatically.
When do I add gosec or nilaway?
Add gosec for security-sensitive services; nilaway when nil panics are frequent.
Enable via golangci-lint plugins or separate jobs - see the dedicated pages in this section.
Related
- Go Tooling Setup Basics - install staticcheck and golangci-lint
- gosec & nilaway for Security and Nil Safety - security and nil analyzers
- govulncheck for Dependency Vulnerabilities - dependency CVE scanning
- Writing Custom go/analysis Analyzers - team-specific rules
- gopls & Analysis Tooling Best Practices - editor and CI alignment
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).