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

ToolWhat it doesWhen to run it
gofmt / go fmtFormats code the one standard wayOn every save
goimportsgofmt plus adding and removing imports (part of gopls)On every save
go vetFinds suspicious code: bad format strings, copied locks, unreachable code, lost cancel functionsBefore every commit; go test runs a subset automatically
go test -raceDetects data races while tests runIn CI, on every change
go fixRewrites code to newer idioms and APIsAfter upgrading Go
staticcheckA widely used third-party linter with hundreds of checksIn CI
golangci-lintRuns many linters (including staticcheck) with one configIn CI
govulncheckReports known vulnerabilities in the code paths you actually useIn 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:

GO
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):

Output
--- 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, not bytes.ByteBuffer. No Get prefix on getters: u.Name(), not u.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 with errors.Is and errors.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.Context as 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.
Next: put everything together in a final project.