Go vet and staticcheck

Run go vet for standard checks and staticcheck for advanced analysis to catch bugs and improve code quality.

The compiler misses the logic

You write a function. It compiles. You run the tests. Everything passes. You deploy to production, and the logs fill with %!s(MISSING) instead of user names. The compiler didn't complain. The types matched. The syntax was perfect. The logic was just wrong.

This is the gap between valid Go and correct Go. The compiler checks if your code follows the grammar and type rules. It does not check if your code does what you intend. Linters fill that gap. They analyze your code for patterns that almost always indicate a bug, a performance issue, or a violation of best practices.

Go ships with a built-in linter called go vet. The community has built a much more aggressive linter called staticcheck. Together, they catch bugs that the compiler ignores and tests might miss.

go vet: the built-in logic checker

go vet runs static analysis on your code. It does not execute the program. It reads the abstract syntax tree and the static single assignment form generated by the compiler. It looks for suspicious patterns.

Run go vet on a package to see it in action.

package main

import "fmt"

// Greet prints a greeting for the given name.
func Greet(name string) {
    // BUG: %d expects an integer, but name is a string.
    // The compiler allows this because fmt.Printf accepts any arguments.
    // go vet will flag this mismatch.
    fmt.Printf("Hello, %d\n", name)
}

func main() {
    Greet("Alice")
}

Save this as main.go and run go vet ./.... The tool reports:

printf format %d has argument of wrong type

The compiler trusts you with the format string. go vet does not. It knows that %d and string do not mix.

go vet runs several checks. Some are built-in. Others are available via the vet toolchain. Common checks include:

  • Format string mismatches.
  • Unreachable code.
  • Struct tag issues.
  • Variable shadowing.
  • Bad usage of sync.Mutex.

Variable shadowing is a frequent source of silent bugs. go vet catches it.

package main

import "fmt"

// Process handles a request and returns an error.
func Process() error {
    // err is initialized to nil.
    err := doSomething()
    if err != nil {
        // BUG: This err shadows the outer err.
        // The inner err is checked, but the outer err is returned.
        // If doAnotherThing fails, the function returns nil.
        err := doAnotherThing()
        if err != nil {
            return err
        }
    }
    // Returns the outer err, which is always nil here.
    return err
}

func doSomething() error { return nil }
func doAnotherThing() error { return fmt.Errorf("oops") }

func main() {
    fmt.Println(Process())
}

Run go vet on this code. It reports:

shadow: declaration of 'err' shadows declaration at line 6

The outer err is never assigned a value inside the block. The inner err hides it. The function returns nil even when an error occurs. go vet exposes this by tracking variable lifetimes.

gofmt handles formatting. go vet handles logic. They are separate tools. Most editors run gofmt on save. You should run go vet before committing code. The convention is to treat go vet failures as build errors.

The compiler checks syntax. Vet checks intent.

staticcheck: the community super-linter

go vet is conservative. It avoids false positives. That means it misses some bugs. staticcheck is more aggressive. It contains hundreds of checks contributed by the Go community. It catches deprecated APIs, useless assignments, potential panics, and context leaks.

Install staticcheck using the module-aware install command:

go install honnef.co/go/tools/cmd/staticcheck@latest

This downloads the binary to your GOPATH/bin. You can run it directly, but the recommended way is to use go vet as a driver.

go vet -vettool=$(which staticcheck) ./...

The -vettool flag tells go vet to replace its analysis engine with staticcheck. This matters because go vet understands modules, build constraints, and package paths. If you run staticcheck directly, you might miss packages or hit build tag issues. Using go vet as the driver ensures the analysis runs on the exact code that go build would compile.

Here is a realistic example. ioutil was deprecated in Go 1.16. The compiler still accepts it. staticcheck warns you.

package main

import (
    "fmt"
    "io/ioutil" // Deprecated since Go 1.16.
)

// ReadFile reads the contents of a file.
func ReadFile(path string) ([]byte, error) {
    // staticcheck flags ioutil.ReadFile as deprecated.
    // The message suggests using os.ReadFile instead.
    data, err := ioutil.ReadFile(path)
    if err != nil {
        return nil, err
    }
    return data, nil
}

func main() {
    data, err := ReadFile("test.txt")
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(string(data))
}

Run go vet -vettool=$(which staticcheck) ./.... The output includes:

SA1019: ioutil.ReadFile is deprecated: As of Go 1.16, the same functionality is now provided by package os, and that is what this function calls.

staticcheck uses codes like SA1019 to identify checks. The message explains the issue and suggests a fix. You can look up the code in the staticcheck documentation to learn more.

staticcheck also checks for context misuse. If you pass a context.Context to a function that ignores it, or if you leak a goroutine by not respecting cancellation, staticcheck warns you.

package main

import (
    "context"
    "fmt"
    "time"
)

// FetchData simulates a network call.
func FetchData(ctx context.Context) error {
    // BUG: The goroutine ignores context cancellation.
    // If ctx is cancelled, this goroutine leaks.
    go func() {
        // The goroutine sleeps forever if the context is cancelled.
        // staticcheck flags this as a potential leak.
        select {
        case <-time.After(10 * time.Second):
            fmt.Println("done")
        }
    }()
    return nil
}

func main() {
    ctx := context.Background()
    FetchData(ctx)
}

staticcheck reports a warning about the goroutine not checking the context. The goroutine waits on a timer that never fires if the context is cancelled. The goroutine leaks.

staticcheck is the collective memory of the Go community. Every check represents a bug someone hit and a rule someone wrote to prevent it.

Configuration and noise

staticcheck is powerful, but it can be noisy. Some checks might not apply to your project. You can configure staticcheck using a .staticcheck.conf file in your project root.

{
    "checks": [
        "all",
        "-ST1000",
        "-QF1008"
    ]
}

The checks field lists which checks to run. all enables everything. You can disable specific checks by prefixing the code with a minus sign. ST1000 checks for unused parameters. QF1008 suggests using strings.Contains instead of strings.Index. Disable checks only when you have a good reason.

You can also suppress checks inline using comments.

//lint:file-ignore SA1019 This package wraps deprecated APIs for legacy support.
package legacy

The //lint:file-ignore comment tells staticcheck to ignore a specific check for the entire file. Use this sparingly. Suppressing errors hides bugs. Suppress style warnings if needed, but logic warnings should stand.

A linter that screams at you gets ignored. Tune it.

Pitfalls and errors

go vet and staticcheck are not perfect. They can produce false positives. A false positive is a warning for code that is actually correct.

If go vet flags a format string that is constructed dynamically, you might see:

printf format %s has argument of wrong type

If the format string is built at runtime, go vet cannot analyze it. You can suppress the warning or refactor the code.

staticcheck might flag a variable as unused when it is used via reflection. staticcheck cannot track reflection. You can suppress the check or add a blank identifier assignment to silence the compiler.

// Used by reflection.
var _ = myStruct{}

The compiler rejects unused imports and variables. go vet and staticcheck catch logic errors. If you get an error like undefined: pkg, you forgot to import a package. If you get imported and not used, you imported a package but didn't use it. These are compiler errors, not linter warnings.

Trust the tool, but verify the report.

Decision matrix

Use go vet when you want a zero-config, built-in check for logic errors and format strings. Use go vet when you need to analyze code with complex build tags or module constraints. Use staticcheck when you want deeper analysis, deprecation warnings, and community-vetted rules. Use staticcheck in CI when you need consistent quality across a team. Use plain go build when you just need to check syntax and types quickly.

The compiler is a grammarian. Linters are editors. Run vet before you commit. Run staticcheck before you merge.

Where to go next