Best Practices and Tooling
Go code tends to look the same wherever you find it, because the community shares a strong set of conventions and the tools enforce many of them automatically. This lesson collects the habits that make Go code idiomatic — formatting, naming, error handling, package design, concurrency — and the tools that check them for you: gofmt, go vet, go fix, the race detector, staticcheck and govulncheck.
Let the tools do the work
| Tool | What it does | When to run it |
|---|---|---|
gofmt / go fmt | Formats code the one standard way | On every save |
goimports | gofmt plus adding and removing imports (part of gopls) | On every save |
go vet | Finds suspicious code: bad format strings, copied locks, unreachable code, lost cancel functions | Before every commit; go test runs a subset automatically |
go test -race | Detects data races while tests run | In CI, on every change |
go fix | Rewrites code to newer idioms and APIs | After upgrading Go |
staticcheck | A widely used third-party linter with hundreds of checks | In CI |
golangci-lint | Runs many linters (including staticcheck) with one config | In CI |
govulncheck | Reports known vulnerabilities in the code paths you actually use | In CI and before releases |
The two third-party tools install with go install honnef.co/go/tools/cmd/staticcheck@latest and go install golang.org/x/vuln/cmd/govulncheck@latest.
Modernising code with go fix
go fix applies modernizers: safe, automatic rewrites to newer language features and library functions. Given this older-style code:
package main import ( "fmt" "sort" "strings" "sync" ) func describe(v interface{}) string { return fmt.Sprint(v) } func main() { nums := []int{5, 2, 8} sort.Ints(nums) largest := nums[0] for _, n := range nums { if n > largest { largest = n } } var wg sync.WaitGroup for i := 0; i < 3; i++ { wg.Add(1) go func() { defer wg.Done() _ = i }() } wg.Wait() if strings.HasPrefix("go.mod", "go") { fmt.Println(strings.TrimPrefix("go.mod", "go")) } fmt.Println(describe(largest), nums) }
go fix -diff ./... shows what it would change (file paths shortened here):
--- main.go (old)
+++ main.go (new)
@@ -7,7 +7,7 @@
"sync"
)
-func describe(v interface{}) string {
+func describe(v any) string {
return fmt.Sprint(v)
}
@@ -23,17 +23,15 @@
}
var wg sync.WaitGroup
- for i := 0; i < 3; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
+ for i := range 3 {
+ wg.Go(func() {
_ = i
- }()
+ })
}
wg.Wait()
- if strings.HasPrefix("go.mod", "go") {
- fmt.Println(strings.TrimPrefix("go.mod", "go"))
+ if after, ok := strings.CutPrefix("go.mod", "go"); ok {
+ fmt.Println(after)
}
fmt.Println(describe(largest), nums)
}Run go fix ./... without -diff to apply the changes, then review them like any other change.
Idiomatic Go in brief
Naming
- Short names for short scopes (
i,r,err), descriptive names for wide ones. - MixedCaps, never underscores; acronyms in one case:
userID,HTTPServer. - Don't repeat the package name:
bytes.Buffer, notbytes.ByteBuffer. NoGetprefix on getters:u.Name(), notu.GetName(). - One-method interfaces are named after the method plus
-er:Reader,Notifier.
Errors
- Handle errors where they occur; return early so the main path stays unindented.
- Add context with
fmt.Errorf("doing X: %w", err); check witherrors.Isanderrors.AsType. - Return errors rather than panicking; never ignore an error silently.
Design
- Make the zero value useful, so types work without a constructor where possible.
- Accept interfaces, return concrete types; keep interfaces small and define them where they're used.
- Prefer composition (embedding, small types) over deep hierarchies.
- Keep packages focused, with a small exported API; use
internal/for shared private code. - A little copying is better than a little dependency: don't add a module for a ten-line function.
Concurrency
- Don't start a goroutine without knowing how it will stop. Pass a
context.Contextas the first parameter to anything that can block. - Let the caller decide about concurrency: write synchronous functions and let callers wrap them in goroutines.
- Protect shared state with a mutex next to the data it guards, or give it a single owning goroutine.
Debugging
For most bugs, a failing test plus a few well-placed fmt.Printf("%+v\n", x) or slog.Debug calls is quickest. For harder ones, Delve is Go's debugger: install it with go install github.com/go-delve/delve/cmd/dlv@latest, then dlv debug or dlv test lets you set breakpoints, step through code and inspect goroutines. VS Code and GoLand use Delve behind their graphical debuggers.
A CI checklist
gofmt -l . # lists badly formatted files; should print nothing go vet ./... go test -race ./... staticcheck ./... govulncheck ./... go mod tidy -diff # fails if go.mod or go.sum need tidying
Where to learn more
- Effective Go and Go Code Review Comments on go.dev — the classic style guides.
- The Go Blog — in-depth articles on new features from the Go team.
- The Go specification — short and readable; the final word on how the language works.
- pkg.go.dev — documentation for the standard library and every public module.