Tooling: go vet, fmt and golangci-lint
Reviewed & published by Brayan K
Idiomatic Go isn't a matter of taste — it's enforced by tools. You'll use gofmt for canonical formatting, go vet and staticcheck to catch bugs the compiler allows, golangci-lint to run many linters at once, and go mod tidy / go build ./... to keep the module healthy — then wire it all into CI.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Format code canonically with gofmt / go fmt
- Catch likely bugs with go vet and staticcheck
- Run many linters together with golangci-lint
- Keep modules clean with go mod tidy and go build ./...
- Understand struct field alignment and padding
- Enforce every check automatically in CI
1️⃣ Canonical formatting with gofmt
Go has exactly one format. go fmt ./... rewrites your files; gofmt -l . lists files that aren't formatted (ideal for CI), and gofmt -d shows the diff. No more style arguments.
# gofmt enforces ONE canonical formatting — no style debates.
# go fmt runs gofmt over the named packages and rewrites files in place.
$ go fmt ./... # format the whole module
$ gofmt -l . # LIST files that are not formatted (great for CI)
$ gofmt -d main.go # show a DIFF of what gofmt would change
# Before gofmt (messy spacing, manual alignment):
# x:=1
# yy := 2
#
# After gofmt (canonical: tabs, aligned, consistent):
# x := 1
# yy := 22️⃣ Catching bugs with go vet
go vet reports constructs that compile but are likely wrong — a classic example is a Printf verb that doesn't match its argument's type.
package main
import "fmt"
func main() {
name := "Ada"
// BUG: %d expects an integer, but name is a string.
// The code COMPILES, but go vet flags the mismatch.
fmt.Printf("hello %d\n", name)
}3️⃣ Many linters at once: golangci-lint
A .golangci.yml at the repo root enables the linters you want (govet, staticcheck, errcheck, unused, and more). One golangci-lint run ./... runs them all with caching.
# .golangci.yml — sits at the repo root.
# golangci-lint runs MANY linters at once with shared config + caching.
linters:
enable:
- govet # the same checks as go vet
- staticcheck # high-quality correctness + simplification checks
- ineffassign # assignments that are never used
- errcheck # unchecked error return values
- unused # unused code
- gofmt # not gofmt-formatted
issues:
exclude-rules:
- path: _test\.go # relax a rule in test files
linters: [errcheck]4️⃣ Modules, builds, and CI
Round it out with go mod tidy (sync dependencies), go build ./... (compile everything), and run the whole set in CI so a bad change can't merge.
# A typical CI pipeline (GitHub Actions step or Makefile target):
# 1. Modules are in sync (no missing/extra requirements)
$ go mod tidy
$ git diff --exit-code go.mod go.sum # fail if tidy changed anything
# 2. Everything compiles
$ go build ./...
# 3. Formatting is canonical (fail if any file is unformatted)
$ test -z "$(gofmt -l .)"
# 4. Static analysis
$ go vet ./...
$ golangci-lint run ./...
# 5. Tests (with the race detector in CI)
$ go test -race ./...🎯 Your Turn
Fix a go vet finding. The format verb doesn't match the argument type — correct it as the comment shows.
package main
import "fmt"
func main() {
count := 3
// 🎯 YOUR TURN — this line has a verb/type mismatch go vet will flag.
// %s expects a string, but count is an int. Fix the verb.
fmt.Printf("count = %s\n", count) // 👉 change %s to %d
_ = fmt.Sprintf // keep the import used
// ✅ After the fix, run: go vet ./... (should report nothing)
}Common Errors (and the fix)
❌ Committing unformatted code — noisy diffs and review friction.
✅ Run go fmt ./... (or save-on-format) and check gofmt -l in CI.
❌ Ignoring go vet warnings — they're usually real bugs.
✅ Treat vet as part of the build and fix every report.
❌ Forgetting go mod tidy — stale or missing requirements.
✅ Tidy after dependency changes; gate it in CI with git diff --exit-code.
❌ Enabling every linter blindly — a wall of noise nobody fixes.
✅ Start with a focused .golangci.yml and add linters deliberately.
🧠 Quick Recall
gofmt vs go vet — which finds bugs?
go vet. gofmt only formats; vet reports suspicious-but-compiling code like bad Printf verbs.
one command, many linters = ?
golangci-lint run ./..., configured by .golangci.yml.
🎯 Mini-Challenge: Make It CI-Clean
Run gofmt -l ., go vet ./..., go mod tidy, and golangci-lint run until every command exits 0 with no output.
package main
// 🎯 MINI-CHALLENGE: make this package CI-clean.
// Run, in order, and fix what each reports:
// 1. gofmt -l . -> reformat any listed file with go fmt ./...
// 2. go vet ./... -> fix any suspicious constructs
// 3. go mod tidy -> remove unused requirements
// 4. golangci-lint run -> address remaining lint issues
//
// ✅ Goal: every command exits 0 with no output, so CI passes.
import "fmt"
func main() {
// Unchecked error (errcheck) and an unused variable would fail lint.
msg := "ship it"
fmt.Println(msg)
}🎉 Lesson Complete!
- ✅ gofmt/go fmt give one canonical format; gate with gofmt -l
- ✅ go vet and staticcheck catch bugs the compiler accepts
- ✅ golangci-lint runs many linters via .golangci.yml
- ✅ go mod tidy + go build ./... keep the module healthy
- ✅ Field alignment cuts padding; wire every check into CI to catch bugs early
Practice quiz
What does gofmt (and go fmt) do?
- rewrites your code into the canonical Go formatting
- runs your tests
- checks for race conditions
- downloads dependencies
Answer: rewrites your code into the canonical Go formatting. gofmt enforces one canonical formatting (tabs, spacing, alignment); go fmt runs gofmt over the packages.
What is go vet for?
- managing modules
- formatting source
- reporting suspicious constructs the compiler accepts but are likely bugs
- compiling for production
Answer: reporting suspicious constructs the compiler accepts but are likely bugs. go vet flags likely mistakes like bad Printf verbs, unreachable code, or copying a lock — things that compile but are wrong.
Which Printf mistake does go vet typically catch?
- a type that has no String method
- a format verb that doesn't match its argument, e.g. %d for a string
- an unused variable
- a missing import
Answer: a format verb that doesn't match its argument, e.g. %d for a string. vet's printf check flags mismatched verbs such as fmt.Printf("%d", "hi") where %d expects an integer.
What does go mod tidy do?
- runs all linters
- vendors dependencies
- formats go.mod
- adds missing and removes unused module requirements
Answer: adds missing and removes unused module requirements. go mod tidy syncs go.mod/go.sum with the imports actually used: adding what's needed and dropping what isn't.
What does go build ./... compile?
- every package in the module recursively
- just the tests
- the standard library
- only the main package
Answer: every package in the module recursively. The ./... pattern matches the current directory and all subdirectories, so it builds the whole module.
What is golangci-lint?
- a code formatter only
- a meta-linter that runs many linters together with shared config and caching
- Go's package manager
- a profiler
Answer: a meta-linter that runs many linters together with shared config and caching. golangci-lint aggregates dozens of linters (govet, staticcheck, ineffassign, ...) behind one config file and fast runner.
Where do you configure which linters golangci-lint runs?
- in main.go
- in the environment only
- in go.mod
- in a .golangci.yml (or .yaml/.toml) file
Answer: in a .golangci.yml (or .yaml/.toml) file. A .golangci.yml at the repo root enables/disables linters and sets their options; flags can override it.
What is staticcheck known for?
- generating mocks
- formatting imports
- a large set of high-quality correctness and simplification checks
- running benchmarks
Answer: a large set of high-quality correctness and simplification checks. staticcheck (the SA/ST/S checks) finds real bugs and suggests simplifications; it's bundled into golangci-lint.
Why does struct field alignment (field order) matter?
- it changes the JSON output
- reordering fields can reduce padding and shrink struct size in memory
- it speeds up compilation
- it is required for the code to compile
Answer: reordering fields can reduce padding and shrink struct size in memory. Because of memory alignment, grouping fields by size cuts padding; the fieldalignment analyzer can report wasteful layouts.
What is the best way to enforce these checks on every change?
- run gofmt -l, go vet, and golangci-lint in CI so a bad change fails the build
- only run them before a release
- rely on the IDE alone
- ask reviewers to run them by hand
Answer: run gofmt -l, go vet, and golangci-lint in CI so a bad change fails the build. Wiring fmt-check, vet, and golangci-lint into CI catches issues automatically on every push and pull request.
Continue this course
- Previous: Reflection with the reflect Package
- Next: Go Workspaces & Vendoring — Multi-module development with go.work, plus vendoring and reproducible builds
Frequently asked questions
What's the difference between gofmt, go fmt, and goimports?
gofmt is the tool that rewrites a file into Go's single canonical format (tabs, spacing, alignment). go fmt is a thin wrapper that runs gofmt over the packages you name. goimports is a popular superset that also adds and removes import statements and groups them. Because formatting is fixed and automatic, Go teams don't argue about style — they run the tool. Use gofmt -l in CI to fail on any unformatted file.
How is go vet different from a linter?
go vet ships with the Go toolchain and reports a curated set of constructs that compile but are almost certainly bugs: Printf verb/argument mismatches, copying a value containing a sync.Mutex, unreachable code, struct tag typos, and more. A linter like staticcheck or golangci-lint goes further — style, simplifications, unused code, unchecked errors — and is configurable. In practice you run both: vet for the high-signal core, golangci-lint for breadth.
What is golangci-lint and why use it over individual linters?
golangci-lint is a meta-linter: it runs many linters (govet, staticcheck, ineffassign, errcheck, unused, and dozens more) in a single fast pass with shared caching and one config file. Instead of installing and invoking each tool separately, you enable the ones you want in .golangci.yml and run golangci-lint run ./.... It's the de facto standard for Go CI. Note it's a third-party tool you install separately from the Go toolchain.
What does go mod tidy do and when should I run it?
go mod tidy reconciles your go.mod and go.sum with the imports your code actually uses: it adds any missing module requirements and removes ones that are no longer needed, and prunes unused checksums. Run it after adding or removing dependencies, and add a CI check (run tidy, then git diff --exit-code go.mod go.sum) so a pull request fails if someone forgot to tidy.
Why does struct field order affect memory usage?
CPUs require fields to sit at aligned addresses, so the compiler inserts padding between fields of different sizes. If you interleave small and large fields, that padding can bloat the struct. Grouping fields from largest to smallest (or by size) minimizes padding and shrinks the struct. The fieldalignment analyzer (available via go vet's analysis tools and golangci-lint) can report structs whose layout wastes bytes.
How do I wire all this into CI?
Add a job that runs, on every push and pull request: go build ./... (it compiles), test -z "$(gofmt -l .)" (formatting), go vet ./..., golangci-lint run ./..., and go test -race ./... (correctness). Each non-zero exit fails the build, so problems are caught before merge rather than in review. Keep the same commands available locally (a Makefile target) so contributors can reproduce CI on their machine.