AlgoMaster Logo

Linting and staticcheck

Medium Priority23 min readUpdated June 6, 2026

The Go compiler is strict about types and unused variables, but it cheerfully accepts code that's almost certainly wrong: a mutex copied by value, an err checked against the wrong variable, a defer that captures a loop variable. Linters catch those. This chapter covers go vet, gofmt, staticcheck, and golangci-lint, what each one finds, how to wire them into your editor and CI, and how to suppress the rare false positive without poisoning your codebase.

Why Lint at All

The compiler's job is to reject programs that can't be translated to machine code. A program that copies a sync.Mutex by value compiles fine, because copying a struct is a legal operation. It just doesn't work: each copy has its own lock state, so the "protection" is theatre. The Go team chose to keep the compiler simple and push these checks into separate tools. This is why every serious Go project runs at least one linter alongside go build.

Here's a function with three real bugs that the compiler accepts without complaint.

This compiles. It even runs without panicking. The bugs:

  1. Add has a value receiver, so each call locks a copy of the mutex. The lock protects nothing.
  2. The inverted check if err == nil { return err } returns nil on success and swallows errors.
  3. saveOrder accepts Cart by value, which copies the mutex (the same lock-copy bug, in a different costume).

go vet catches #1 and #3. staticcheck catches #2 (the inverted-condition pattern) and gives a clearer message about the mutex copy. A reader skimming this code would miss all three. That's the point of linting: take the bugs the compiler can't see and surface them automatically before the code reaches review.

Running linters in CI adds 10-60 seconds to a build, depending on package count. That's cheap compared to a production page-out caused by a swallowed error.

go vet: The Built-In Linter

go vet ships with the toolchain. It's enabled by default when you run go test, which means a vet failure on your test files breaks the build automatically. You can also invoke it directly.

go vet runs a fixed set of analyzers maintained by the Go team. The list is conservative on purpose: every check has to have a near-zero false positive rate, because vet failures break go test. A short tour of the most useful ones:

AnalyzerCatches
copylocksA struct containing a sync.Mutex, sync.RWMutex, etc. being copied (passed by value, returned by value)
printfMismatched format verbs (fmt.Printf("%d", "hello"))
shadowA variable shadowing one of the same name in an outer scope (off by default)
unreachableCode after return, panic, or an infinite loop
loopclosureA goroutine or deferred call that captures a loop variable in pre-1.22 code
nilfuncComparing a function value to nil (almost always a typo)
structtagMalformed struct tags (json:"name" missing quotes, duplicate keys)
assignSelf-assignment like x = x

What go vet does not catch is anything controversial. It won't flag unused parameters, won't complain about variable names, won't reformat your code. Those belong to other tools. The contract is "vet only flags things that are almost certainly bugs", and the Go team holds that line carefully.

That Printf typo would print %!d(float64=19.99) at runtime, which many developers would notice. But it might first appear in a production log line that nobody reads carefully. Vet catches it before the binary ships.

gofmt and gofumpt: Formatting Isn't Optional

gofmt is the canonical formatter. Every Go project runs it. Style debates over braces, tabs vs spaces, and import ordering don't exist in Go, because gofmt has already decided. Run it with gofmt -w. to rewrite every file in place, or gofmt -l. to list files that aren't already formatted.

Two files are unformatted. CI should fail the build when that happens. The standard pattern is:

That exits non-zero if gofmt -l printed anything. Most teams wire it into a pre-commit hook so developers never push unformatted code.

gofumpt is a stricter superset by Daniel Martí. It applies a small set of additional rules: composite literals use one element per line when they span multiple lines, package-level vars are grouped, short if err != nil chains use the standard form. The rules are deliberately small and uncontroversial, and they're a strict superset of gofmt, meaning gofumpt-formatted code is always gofmt-formatted too.

You don't have to use gofumpt. Some teams find the extra opinions helpful, others stick with plain gofmt. What you can't do is leave formatting to personal preference. Either choose gofmt or choose gofumpt, set it as the formatter in every editor in the team, and check it in CI. Inconsistent formatting in diffs makes code review miserable.

Reformatting after the fact creates large diffs that obscure real changes. Configure your editor to format on save from day one; retrofitting gofumpt across an existing repo means landing a single "format everything" PR before anyone reviews the next feature change.

staticcheck: The Heavy Hitter

staticcheck is the most widely used third-party Go linter. It runs around 150 checks grouped by prefix, and the project documents each one with examples. The categories:

PrefixMeaningExamples
SABugs (StaticAnalysis)SA1000 invalid regex, SA4006 unused write, SA5007 infinite recursion
SSimplificationsS1005 unnecessary blank identifier, S1011 use append for slice merge
STStyleST1003 underscores in names, ST1016 inconsistent receiver names
UUnusedU1000 unused function, type, field, constant
QFQuick fixesQF1003 convert if/else chain to switch

The SA checks are the ones you absolutely want on. They find real bugs: a time.Tick leak, a defer inside a loop, an error checked against the wrong variable.

The bug is real. strings.ToUpper(code) produces an upper-case string, but the comparison is to "save10", which is lower-case. The condition is dead code, and the discount never applies. go vet doesn't catch this. staticcheck does, with a precise explanation.

Another common one:

We threw away the file handle without closing it. Over time that exhausts the file descriptor table. go vet doesn't catch resource leaks because doing so requires escape analysis that's expensive to run. staticcheck accepts the cost in exchange for the coverage.

You install it once:

The configuration file is staticcheck.conf at the module root:

The -ST1000 and -ST1003 entries turn off the "package needs a doc comment" and "underscores in names" checks. Pick which style rules your team cares about; staticcheck doesn't force them all.

golangci-lint: The Meta-Runner

Running go vet, staticcheck, errcheck, ineffassign, and a handful of others as separate commands gets old. golangci-lint is a runner that compiles each linter as a library, loads your code once, and runs every analyzer against that single AST. It's faster than running each tool separately because the parsing and type-checking happen exactly once.

A minimal .golangci.yml at the module root:

A few notes on what each enabled linter does:

LinterWhat it catches
errcheckIgnored error return values (json.Marshal(x) without if err != nil)
govetSame checks as go vet, run through the meta-runner
ineffassignAssignments to variables that are never read
staticcheckThe full SA/S/ST/U/QF catalog
unusedUnused functions, methods, constants, struct fields
gosecSecurity smells: SQL string concat, weak crypto, hardcoded credentials
reviveConfigurable style rules (replacement for the older golint)
gocriticWide-ranging code suggestions, opinionated but useful

The default: none line is important. golangci-lint ships with a default set of linters that has shifted between versions; pinning your own set keeps CI deterministic. Explicit beats implicit, especially in a config file that controls whether the build is green.

Run it:

Each finding includes the linter name in parentheses. That's useful when you need to suppress one: you have to name it, and the name appears in the error.

golangci-lint typecheck happens once for all linters, but if you enable too many overlapping ones (say, staticcheck, gocritic, and revive with default rules), you'll get duplicate findings on the same lines. Tune your enabled set; fewer linters with curated rules beats every-linter-on.

A Lint Pipeline End to End

Putting the pieces together, here's the shape of a typical lint pipeline running in CI.

The order matters. gofmt runs first because it's the cheapest. Then go vet (small, fast, broadly applicable). Then golangci-lint, which includes staticcheck and the slower analyzers. Finally go test. Each stage blocks merge if it finds issues. A developer who fails on stage 1 doesn't even reach stage 4, which keeps CI minutes cheap.

A complete GitHub Actions workflow:

This is the minimum useful setup. Each step is one command. If any step exits non-zero, the workflow fails and the PR can't merge (assuming branch protection is configured). The whole thing runs on every push and every PR.

A pre-commit hook catches the same problems on the developer's laptop before the push even leaves their machine. With pre-commit (the Python tool, widely used in Go projects too):

The first hook runs golangci-lint on changed files. The second runs gofmt -w to auto-format. Developers install the hooks once with pre-commit install and the checks fire on every commit. That's the cheapest possible feedback loop, way faster than waiting for CI.

Suppressing False Positives

Linters get things wrong sometimes. A gosec warning about a hardcoded "password" might be a test fixture. An errcheck complaint about an ignored close might be deliberate in a tear-down path where you can't do anything useful with the error. The way to silence them is the //nolint directive.

The directive has three parts: nolint, the linter name, and a comment explaining why. The last part is the one developers skip and the one reviewers should insist on. A //nolint:gosec with no justification is a code smell; the reader has no idea whether the suppression is sound. A //nolint:gosec // MD5 is fine for non-security lookup keys is self-documenting.

A few rules for keeping suppressions sane:

  1. Name the specific linter. Bare //nolint disables every linter on the line, which is too coarse.
  2. Always include a justification. "Why is this not a bug?" is a question reviewers should answer once at the suppression site.
  3. Suppress at the smallest scope. A line-level directive is better than a file-level one. A file-level one is better than disabling the linter globally.
  4. Re-evaluate periodically. Linters improve. A suppression that made sense two years ago might no longer be needed.

You can also configure golangci-lint to exclude paths or patterns wholesale in .golangci.yml:

The first rule says "in test files, ignore errcheck and gosec findings entirely". That's a defensible trade-off: tests often deliberately set up broken state, and pedantic error handling in tests reads worse than it does in production code. The third rule turns off the "exported identifier needs a doc comment" check inside internal/, because internal packages aren't part of the public API and don't need that level of documentation polish.

Suppressions accumulate. Without periodic review they grow into a "deny list" of things the team has given up on. Run golangci-lint run --no-default --enable nolintlint periodically to find suppressions that no longer apply.

Real Lint Findings in E-Commerce Code

The abstract case for linting is fine, but seeing actual findings drives the point home. Here are four examples drawn from real Go code patterns in shopping-cart and order-processing flows.

Finding 1: Wrong error variable checked.

staticcheck says:

The author meant to check both errors but only checked the first. Stock errors are swallowed. The fix is to name them distinctly and check both, or use the standard pattern of returning early on each error.

Finding 2: Defer inside a loop.

gocritic flags this as deferInLoop. The bug: every defer f.Close() waits for ProcessOrders to return. If you process 10,000 order files, you accumulate 10,000 open file handles before the first close runs. The fix is to wrap each iteration in a helper function so each defer fires at the end of one iteration.

Finding 3: Ineffective assignment.

ineffassign and staticcheck (SA4006) both flag the first assignment to total inside SAVE10. Every iteration recomputes total from price, not from the previous total. With both codes, the function returns 80 (only SAVE20 was applied), not 72 (10% then 20%). The discount stacking is broken.

Finding 4: Errors not wrapped.

wrapcheck (a separately enabled linter) flags both return nil, err lines. The errors propagate up without context, so a caller seeing open missing.json: no such file or directory doesn't know it came from LoadOrder. The fix is fmt.Errorf("read order file: %w", err) and fmt.Errorf("unmarshal order: %w", err). Now the chain says where the error happened and what was being attempted.

Each of these is a real bug shipped to production at some point in some company's codebase. Each takes seconds to spot if a linter flags it before review.

Fix As You Go vs Big-Bang Cleanup

When you adopt linting on an existing codebase, you face a choice: fix every finding before you ship the new configuration, or accept the backlog and only fail builds on new findings. Both work; they have different trade-offs.

The big-bang approach is to run the linters, fix everything, land one giant PR, and require zero findings going forward. It's clean. The codebase converges to a single quality bar fast. The downside is the PR: thousands of changes across hundreds of files, impossible to review properly, blocking everything else for days. For a small codebase (under 50K lines), this is usually fine. For a large one, it's a nightmare.

The fix-as-you-go approach treats existing findings as a "baseline" and only enforces zero findings on new or modified code. golangci-lint supports this through --new-from-rev:

That command only reports findings on lines that differ from main. Existing technical debt stays in place; new code can't add to it. Over time the backlog drains as files get touched for unrelated reasons. Six months in, most of the easy findings are gone, and you can flip the flag to require zero findings everywhere.

A hybrid pattern: pick one linter at a time, fix everything it finds, enable it strictly, move to the next. Start with the linters that find real bugs (govet, staticcheck's SA checks, errcheck). Save the style-focused ones (revive, gofumpt) for after the bug-finders are clean. The team gets visible quality wins from week one and avoids the giant style-only diff that nobody wants to review.

Big-bang style-only PRs make git blame worse forever. Every "format the world" commit hides the real author of every line. If you do a big-bang cleanup, add the commit to .git-blame-ignore-revs so blame skips over it.

Quiz

Linting & staticcheck Quiz

10 quizzes