go / checks

I run five checks before committing Go code:

goimports -local "$(go list -m)" -w .
go vet ./...
go test ./...
deadcode -test ./...
gopls check -severity=hint ./*.go

Order

The checks run fast-to-slow to fail fast:

  1. goimports: Formats code and fixes imports. Runs first so other tools see properly formatted code.
  2. go vet: Static analysis. Catches bugs before spending time on tests.
  3. go test: Runs tests. No point running if vet already found issues.
  4. deadcode: Finds unreachable functions. Slowest (whole-program analysis), informational.
  5. gopls: Reports the same diagnostics the editor shows. Loads the whole workspace, so it runs last.

goimports

goimports formats code like gofmt and also adds/removes imports.

go install golang.org/x/tools/cmd/goimports@latest

I install this via my laptop script.

The -local flag groups imports into three sections: standard library, third-party, and local module.

import (
    "fmt"
    "net/http"

    "github.com/someone/pkg"

    "mymodule/internal/foo"
)

go vet

go vet reports likely mistakes: printf format errors, unreachable code, suspicious constructs.

It's built into Go and runs fast.

go test

go test runs tests.

The ./... pattern matches all packages in the module.

Assertions come from is, so a failure names the value it checked without a hand-written message.

deadcode

deadcode finds functions that are never called.

go install golang.org/x/tools/cmd/deadcode@latest

I install this via my laptop script.

It uses whole-program analysis starting from main, so it only works on executables, not libraries.

The -test flag includes test binaries in the analysis:

deadcode -test ./...

This creates a virtuous cycle for codebase quality. When deadcode reports an unreachable function, you have two options:

  1. Remove it. The function is genuinely unused.
  2. Add a test. The function is used but not covered by tests.

Either outcome improves the codebase: less dead code or better test coverage.

The -test flag is especially useful for projects with multiple entry points (WASM, CLI tools, etc.) where some functions are only reachable from entry points that deadcode can't analyze natively.

gopls

gopls, the Go language server, surfaces diagnostics beyond go vet: modernizers that flag older idioms in favor of newer standard-library APIs, and correctness hints like an unchecked bufio.Scanner.Err. Running it as a check catches these before review rather than leaving them as editor squiggles.

go install golang.org/x/tools/gopls@latest

The -severity=hint flag lowers the exit threshold so even hint-level diagnostics fail the check. gopls check takes file paths, not package patterns, so pass ./*.go rather than ./....

On a large repo, checking every file is slow because gopls loads and type-checks the whole workspace. There, scope the check to the files a branch changed against main:

git diff --name-only --diff-filter=d origin/main...HEAD -- '*.go' |
  xargs gopls check -severity=hint

A protected main means every change lands through a checked merge, so the only gap is a Go or gopls upgrade adding hints to untouched files. Catch that with a one-off full scan:

git ls-files -z '*.go' | xargs -0 gopls check -severity=hint

Pinned tools

A check is only reproducible if everyone runs the same version of the tool. Go 1.24 added the tool directive for this, and my SQL formatter uses it:

go get -tool github.com/croaky/pgfmt/cmd/pgfmt
go tool pgfmt -c <pkg>/queries/*.sql

The version lives in go.mod, so go tool builds that version on my laptop and on a CI box alike. There is nothing to install and nothing on $PATH. An upgrade is a commit that also reformats whatever the new version formats differently, which is what I want for a formatter whose output is checked into the repo.

This also beats keeping the tool in the repo as cmd/pgfmt and running go run ./cmd/pgfmt. As a module, one copy serves several repos, and each repo upgrades on its own schedule.

The five checks above are still go install ...@latest from my laptop script, which means the version a machine has is a function of the day I last ran it. Two CI boxes provisioned a month apart run different linters, and a reprovision can turn a green branch red with no commit to point at. gopls is the one that bites, since -severity=hint gates on advice rather than correctness, and scoping it to changed files is a workaround for exactly that.

I have not moved them yet because of what they drag in. goimports and deadcode add five indirect requires between them, most of which the app module already has. gopls is its own module and adds 23, and a tool directive's requires join the main module's build list, so a linter's dependency can raise a version the shipped binaries link against.

How they run

The same commands live in two places, so I rarely run them by hand:

Project-specific checks live alongside these. In one repo the Checkfile also runs nullscan, a static check for pgx scans of nullable columns into non-nullable Go types.

Security: govulncheck

govulncheck uses the Go Vulnerability Database and static analysis to filter out vulnerabilities that don't affect your code. Dependabot can't do this, so it opens noisy PRs for vulnerabilities in packages you don't even call. See Filippo Valsorda's Turn Dependabot Off.

Run periodically:

go run golang.org/x/vuln/cmd/govulncheck@latest ./...

Test against latest dependencies

Test against the newest dependency versions to catch breakage early:

go get -u -t ./...
go mod tidy
go test ./...
git checkout go.mod go.sum  # restore pinned versions

← All articles