DEV Community

Cover image for staticcheck for Go: The Analyzer Rules That Pay for Themselves
Gabriel Anhaia
Gabriel Anhaia

Posted on

staticcheck for Go: The Analyzer Rules That Pay for Themselves


You've inherited a Go service. The CI pipeline runs go vet and
it passes green on every push. The team treats that green as
"the code is clean." Then a nil pointer panic wakes up on-call,
and the postmortem shows a %w verb that was actually a %v, an
error that got swallowed, a loop that never advanced. Every one of
those was sitting in the code the whole time. go vet never had a
check for it.

staticcheck does. It's the analyzer behind golangci-lint's
default lint set, and it ships four rule families: SA (bugs),
ST (style), S (simplifications), and QF (quickfixes). The
SA family alone catches a class of Go bugs that compile fine,
pass go vet, and read as reasonable in review. The rules that
earn their keep are a minority of the set, and the noise around
them is easy to silence without teaching your team to ignore the
tool.

Running it

staticcheck is a single binary. Install it, point it at your
module, done.

go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...
Enter fullscreen mode Exit fullscreen mode

It works on recent Go releases and tracks the newest ones as they
ship.
No config file needed to start. If you already run
golangci-lint, the staticcheck linter is on by default and
covers the SA set, but the standalone binary gives you the full
ST and S families too.

The output is grep-friendly: file:line:col: message (CODE). The
code in parentheses is what you act on. SA5001, ST1005,
S1002. Learn to read the code, not just the message.

The SA rules that catch real bugs

SA is the family that matters most. These are not style
opinions. They are patterns the compiler accepts and the runtime
punishes.

SA5001 — deferring before checking the error. You open a
resource, defer its close, then check whether the open failed.
The defer runs on a nil handle.

f, err := os.Open(path)
defer f.Close() // f may be nil here
if err != nil {
    return err
}
Enter fullscreen mode Exit fullscreen mode

If os.Open failed, f is nil, and the deferred f.Close()
panics on return. staticcheck flags the ordering. The fix is to
check err before you defer.

SA4006 — a value assigned and never used. The classic
shadowed-return bug. You assign to err, then overwrite it
before anyone reads it.

data, err := fetch()
if err != nil {
    return err
}
data, err = parse(data) // err from parse never checked
return process(data)
Enter fullscreen mode Exit fullscreen mode

go vet stays quiet. staticcheck reports that the second err
is written and never read. That is almost always a dropped error
path.

SA9003 — an empty branch. An if or else with nothing in
the body, usually a refactor that left the condition behind.

if err != nil {
    // TODO: handle
}
Enter fullscreen mode Exit fullscreen mode

The check tells you a branch decides nothing. On error paths
that's a swallowed failure hiding as a placeholder.

SA4010 — the result of append is never used. append may
return a new slice header. Ignoring the return means your write
sometimes vanishes.

func addTag(tags []string, t string) {
    tags = append(tags, t) // never read after this
}
Enter fullscreen mode Exit fullscreen mode

tags is a value parameter, so the reassigned slice dies at the
return and the caller never sees the new element. staticcheck
flags the assignment that goes nowhere.

SA1006 — a Printf call with a dynamic format string. When
the format string isn't a constant and you pass no further
arguments, any % in the data is read as a verb.

fmt.Printf(userInput) // SA1006
Enter fullscreen mode Exit fullscreen mode

If userInput ever contains a %s, Printf hunts for an
argument that isn't there and prints %!s(MISSING) into your
output. The fix is fmt.Print(userInput) or
fmt.Printf("%s", userInput) so the data is never treated as a
format string.

The through-line: each of these compiles and looks like ordinary
Go. The bug is behavioral, and it ships.

The ST rules that keep a codebase readable

ST is style. Style checks are worth less than bug checks, but a
few of them enforce conventions that a reviewer would otherwise
have to repeat by hand on every PR.

ST1005 — error strings. Go's convention is that error
strings are lowercase and don't end with punctuation, because
they get wrapped into larger sentences.

return errors.New("Failed to connect.") // ST1005
Enter fullscreen mode Exit fullscreen mode

The fix is errors.New("failed to connect"). Wrapped into
fmt.Errorf("dial db: %w", err), the lowercase form reads right.
The capitalized one reads like two shouted fragments.

ST1016 — receiver names. Methods on the same type should use
the same receiver name. Mixing func (c *Conn) and
func (conn *Conn) on one type is the kind of drift that creeps
in across authors. ST1016 pins it down.

ST1003 — naming conventions. Catches Http where the
convention is HTTP, userId where it should be userID. Small
on its own, but it stops a codebase from splitting into two
naming dialects.

A caution: the ST family is opinionated, and not every team
shares the opinions. Turn on the ones that match your existing
conventions. Turning on all of ST and then arguing about each
finding is how a linter loses the room.

The S rules that delete code

S is simplifications. These don't find bugs. They find code
that a more fluent Go reader would have written shorter, and the
shorter version is usually clearer.

S1002 — comparing a bool to a literal.

if ok == true { // S1002
Enter fullscreen mode Exit fullscreen mode

becomes if ok. S1005 turns for i, _ := range xs into
for i := range xs, dropping a trailing blank identifier.
S1028 replaces
errors.New(fmt.Sprintf(...)) with fmt.Errorf(...). S1011
collapses a for loop that appends every element into a single
append(dst, src...).

None of these change behavior. What they do is teach the idiom by
example. A junior developer who sees S1028 fire a few times starts
reaching for fmt.Errorf on their own. The linter becomes a
style tutor that never gets tired.

Suppressing false positives without going numb

Here is where teams break the tool. A finding looks wrong, so
somebody disables the check globally, and now the rule that would
have caught next month's bug is off for everyone. Suppression
should be the narrowest possible scope, and it should explain
itself.

staticcheck reads a specially formatted line comment directly
above the offending line:

//lint:ignore SA1019 grandfathered until v2 API lands
resp, err := client.OldFetch(ctx, req)
Enter fullscreen mode Exit fullscreen mode

The format is //lint:ignore CODE reason. The code scopes the
suppression to exactly one rule. The reason is mandatory in
spirit, and staticcheck will actually report an unused //lint:ignore
directive if the finding it targeted goes away, so stale
suppressions don't rot silently.

For a whole file or package, prefer config over scattered
comments. A staticcheck.conf at the module root:

# staticcheck.conf
checks = ["all", "-ST1003", "-ST1000"]
initialisms = ["API", "DB", "HTTP", "ID"]
Enter fullscreen mode Exit fullscreen mode

"all" turns everything on, then the - entries subtract the
specific rules you've decided your team won't follow. This is the
honest way to disable a rule: it's in version control, it's
reviewed, and the next person can see what you turned off and
why the list looks the way it does.

Two rules for keeping suppression healthy. First, suppress by
code, never by disabling staticcheck for a file wholesale — a
blanket //lint:file-ignore hides the next real bug in that file
too. Second, when you write //lint:ignore, write the reason as
if the person reading it doesn't have context, because in six
months that person is you.

Wiring it into CI

The point of any analyzer is that a human never has to remember to
run it. Put staticcheck in the same job that runs your tests, and
fail the build on any finding.

lint:
    staticcheck ./...
    go vet ./...
Enter fullscreen mode Exit fullscreen mode

Run go vet too — the two overlap but neither is a superset of
the other. go vet owns some checks staticcheck doesn't
duplicate, and staticcheck owns the whole SA bug family vet
never had.

One migration note if you're adding this to an old codebase: the
first run will light up. Don't fix all of it in one PR and don't
disable everything to get green. Turn on SA first, fix that,
then add S, then decide on ST rule by rule. A linter you
adopt in layers sticks. A linter you bolt on all at once gets
reverted the first time it blocks a hotfix.

What it buys you

staticcheck is not a replacement for review, and it won't catch
the bug where the logic is wrong but the syntax is fine. What it
catches is the layer below that: the %w that's a %v, the
append whose result you dropped, the error you assigned twice
and read once. Those never show up in a demo. They show up at
2 a.m. Moving them to a CI failure at 2 p.m. is the whole trade.

The SA family is where the return is highest, so start there.
Add S for the free readability. Adopt ST where it matches
conventions you already hold. And keep suppression narrow and
documented, because a linter the team has learned to ignore is
worse than no linter at all — it's a green checkmark that means
nothing.


Static analysis lives at the boundary between the language and
your architecture, which is exactly where two of my books sit. The
Complete Guide to Go Programming
goes deep on the runtime and
language semantics that make bugs like SA5001 and SA4006 possible
in the first place, so the findings stop being cryptic codes and
start reading as things you understand. Hexagonal Architecture in
Go
is about keeping that discipline at the right boundary — where
a clean port-and-adapter layout gives staticcheck less surface to
find problems on.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)