Idioms, Style and Code Review

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. Go has a strong house style. Most of it is enforced by tools (gofmt, go vet), a good part of it is agreed convention that reviewers apply by hand, and a smaller part is judgement about API design. This page collects the convention and the judgement in one place: where the conventions come from and how old each source is, the whole of the official Code Review Comments page translated into today’s Go, the idioms that the books and the community guides repeat most often (each with a runnable example from the shelf library catalogue), how to design the API of a package, and a checklist for reviewing a change.

It builds on the language pages and mostly points back to them instead of repeating them: Interfaces, Methods and Receivers, Pointers and Value Semantics, Error Handling, Context, Packages, Imports and Initialization, Documentation and Doc Comments, Formatting, vet, fix and Static Analysis and Testing. The traps that these idioms avoid are listed, with the analyzer that catches each, in Common Mistakes and Gotchas; code written for older releases is modernized in What’s New and Migrating Older Code. The language background (why Go leaves things out) is in Design Philosophy and Coming from Other Languages. For the same exercise in other languages on this site, see Rust: idioms, patterns and API guidelines.

Every example on this page was compiled, vetted with go vet and run with Go 1.27.2; the output shown is the real output, and compiler and vet messages are copied from real runs. The go.mod of the example module is:

module example.com/shelf

go 1.27

Where idioms come from

Go has no single style document with the authority of a standard. Style is spread over official texts of very different ages and over community guides, and a reviewer has to know how much weight each deserves.

Source What it is How to use it today

The Go Programming Language Specification

The language definition. It says what is legal, never what is good.

Final word on semantics. It contains no style rules.

Effective Go

The tutorial on writing clear, idiomatic Go, written in the early years of the language and never restructured since. It does not describe modules, generics, context, log/slog, iterators or most of today’s tooling.

Still the best description of the core ideas (names, composition, interfaces, goroutines and channels, errors). Treat every library or tooling statement as possibly dated.

Go Code Review Comments

A wiki page of the comments that come up again and again in reviews of Go code, maintained by the Go team. It complements Effective Go.

The closest thing to an official checklist. Covered item by item below.

Go Doc Comments

The current rules for doc comments, links, lists and headings.

Authoritative for comment syntax; see Documentation and Doc Comments.

Package names and the other Go Blog posts

Short essays on naming, errors and contexts by the Go team.

Authoritative for the rationale; read the date, because some posts predate current APIs.

Google Go Style Guide (community)

Google’s internal guide made public: a normative guide plus non-normative "decisions" and "best practices" documents. Written for one very large codebase.

A well-argued second opinion. Not binding outside Google.

Uber Go Style Guide (community)

Uber’s guide with many concrete before and after snippets.

Useful, but some advice targets problems the standard library has since solved (see The Uber guide).

Go Proverbs (community)

Rob Pike’s 2015 talk reduced to one-line maxims.

Memorable summaries, not rules. Each is explained by one of the language pages.

The books (Bodner, Vivien)

Learning Go (1st edition, 2021) teaches the idioms in an opinionated way; Learning Go Programming (2016) is older.

Good for the idioms, outdated for the tooling and the library (see Where the books are outdated).

The rule of thumb used on this page: the tools win, then the Go team, then the community. If gofmt or go vet has an opinion, follow it; if the Go team has written a rule down, follow it unless you have a reason you can state in the review; if only a community guide says so, treat it as a recommendation. A style point that nobody can name a reason for is a matter of local consistency, which both the Google and the Uber guides rank above any individual preference.

The Google guide states its priorities explicitly, in this order: clarity (the reader can tell what the code does and why), simplicity (the least mechanism that does the job), concision (a high signal-to-noise ratio), maintainability and consistency. They are a useful tie-breaker in a review discussion.

Effective Go: what still holds

Effective Go has not changed in structure for years. Its sections and what to take from each today:

Section Lasting advice Dated or superseded by

Formatting

Let a tool format the code; do not argue about layout.

Run gofmt (or go fmt); Go 1.27 changed alignment details in some places, see Formatting, vet, fix and Static Analysis.

Commentary

Doc comments are full sentences that begin with the name they document.

Headings, lists and links in doc comments (Go 1.19); see Documentation and Doc Comments.

Names

The package name is part of every use (bufio.Reader, not bufio.BufReader); no Get prefix on getters (Owner, SetOwner); one-method interfaces are named after the method plus er; MixedCaps, never underscores.

Nothing important. Generic type parameters add short single-letter names by convention.

Control structures

Drop the else after a return; use switch with no expression instead of chains of if; the blank identifier discards a value deliberately.

for range n (Go 1.22), range over functions (Go 1.23), min and max (Go 1.21); see Control Flow.

Functions

Multiple results, named results for documentation and for defer, defer for cleanup.

Nothing important; read it together with the guidance on naked returns below.

Data

new versus make, composite literals, the zero value, slices and maps, append, printing.

new(expr) (Go 1.26), clear (Go 1.21), the slices and maps packages; fmt is now complemented by log/slog.

Initialization

Constants and iota, variables, init.

Prefer explicit construction to init (see Avoid init).

Methods

Pointer versus value receivers.

Unchanged; the receiver rules are repeated in Interfaces, types and values.

Interfaces and other types

Small interfaces, type assertions, interface conversions, "generality" (return the interface only if implementations can differ).

Generics (Go 1.18) now cover cases that needed interfaces plus assertions.

Embedding

Composition by embedding; the embedded type’s methods are promoted.

Unchanged; see Structs and Embedding.

Concurrency

Share memory by communicating; goroutines are cheap; channels of channels; select.

context, sync.WaitGroup.Go (Go 1.25), errgroup, testing/synctest; see Concurrency Patterns. The "web server" example at the end uses long-removed APIs.

Errors

Errors are values; return them, add context, panic only for the unrecoverable and never across a package boundary.

Wrapping with %w, errors.Is, errors.As, errors.Join and errors.AsType; see Error Handling.

The advice about the zero value, small interfaces, composition and errors-as-values is what the rest of this page builds on.

Go Code Review Comments

The wiki page Go Code Review Comments has 32 short sections, in alphabetical order. They are summarised here in our own words, grouped by theme, with the current form and the tool that catches the problem where there is one. Each heading links to the original section so that the wording can be checked.

Formatting, comments and naming

Item What the reviewer asks for Today

Gofmt

Run gofmt on all code; goimports additionally fixes the import list.

Format on save in the editor and in CI (gofmt -l prints unformatted files). gopls can organise imports.

Comment sentences

Comments on declarations are full sentences that start with the declared name and end with a period, so they read well in generated documentation.

Unchanged.

Doc comments

Every exported name has a doc comment, and so does every non-trivial unexported type or function.

Unchanged; go doc shows exactly what readers will see (Doc comments in practice).

Package comments

The package comment sits directly above the package clause with no blank line, and starts with Package name. For a command, the comment starts with the command’s name (capitalised when it is the first word).

Unchanged.

Examples

A new package comes with a runnable Example function or a test that shows a complete call sequence.

Testable examples are verified by go test; see Testing.

Mixed caps

Multi-word names use MixedCaps or mixedCaps, never underscores or ALL_CAPS, even for constants.

Unchanged: an unexported constant is maxLength.

Initialisms

Initialisms keep one case: URL or url, ServeHTTP, appID, xmlHTTPRequest. Generated code is exempt.

Unchanged.

Package names

Do not repeat the package name in identifiers (chubby.File, not chubby.ChubbyFile), and avoid meaningless package names such as util, common, misc, api, types and interfaces.

Unchanged; see Package names and layout.

Receiver names

Use one or two letters derived from the type (c for Client), the same in every method; never this, self or me.

Unchanged.

Variable names

Names get shorter the nearer their use is to their declaration: i for a loop index, r for a reader in a short function, a descriptive name for a package-level variable.

Unchanged.

Line length

There is no fixed limit. Break lines for meaning, not for width, and if lines are long, shorten the names or change the function boundaries.

Unchanged.

The shelf catalogue shows what "the package name is part of every use" means in practice:

Avoid Prefer Why

catalog.CatalogBook

catalog.Book

Callers already wrote catalog..

catalog.NewCatalogStore()

catalog.NewStore()

The package name carries the noun.

util.ParseISBN

catalog.ParseISBN

The function belongs with the type it validates; a package called util says nothing.

loan.LoanState

loan.State

Stutter again.

book.GetTitle()

book.Title()

Getters have no Get prefix; the setter is SetTitle.

ISBN.Url()

ISBN.URL()

Initialisms keep one case.

func (self *MemStore) Put(...)

func (m *MemStore) Put(...)

The receiver is an ordinary parameter, named after the type.

Errors and control flow

Item What the reviewer asks for Today

Handle errors

Never discard an error with _. Handle it, return it or, in truly exceptional cases, panic.

errcheck and staticcheck find ignored errors; see Error Handling.

Error strings

Error strings are not capitalised and do not end with punctuation, because they are embedded in longer messages. This does not apply to log lines.

Unchanged; see the example below.

In-band errors

Do not signal failure with a special value such as -1, "" or nil. Return an extra result, an error or a bool, as the last value.

Unchanged; see the example below.

Indent error flow

Handle the error first and return, so the normal path stays at the left margin. Do not write else after a return. If an if has an initialiser that declares the value you need afterwards, move the declaration out.

Unchanged; the linters staticcheck and revive report the redundant else.

Don’t panic

Use error and multiple results for ordinary failures, not panic.

Unchanged. Must helpers are acceptable for initialisation that cannot fail at run time (for example regexp.MustCompile of a literal).

Named result parameters

Name results when it helps the documentation (several results of the same type, or an unclear meaning) or when a deferred closure must change them. Do not name them only to avoid declaring a variable.

Unchanged.

Naked returns

A return with no operands returns the named results. The page defines the term and points back to the previous item; the stricter rule appears in the style guides and in the books (see Never use naked returns).

Unchanged; go vet and the compiler reject the dangerous variants (shadowed results).

Two of these rules produce compile-time or run-time differences you can see. The error-string rule is purely about how messages compose:

package main

import (
	"errors"
	"fmt"
	"os"
)

func main() {
	good := errors.New("open catalogue: file missing")
	bad := errors.New("Open catalogue: File missing.")
	fmt.Printf("loading shelf: %v\n", good)
	fmt.Printf("loading shelf: %v\n", bad)
	_, err := os.Open("/nonexistent/shelf.json")
	fmt.Println(err)
}
loading shelf: open catalogue: file missing
loading shelf: Open catalogue: File missing.
open /nonexistent/shelf.json: no such file or directory

The first message composes into loading shelf: open catalogue: file missing, which reads as one sentence; the second shows a capital letter and a full stop in the middle of a line. The standard library follows the same rule, as the last line shows (open /nonexistent/shelf.json: no such file or directory).

An in-band error is a value from the normal result range that means "failed". The risk is that it flows on into code that treats it as data. The fix is a second result, and the compiler then forces the caller to deal with it:

package main

import (
	"fmt"
	"strconv"
)

var data = map[string]string{"pages": "320"}

// LookupBad signals a missing key in-band with the empty string.
func LookupBad(key string) string { return data[key] }

// Lookup reports whether the key exists.
func Lookup(key string) (string, bool) {
	v, ok := data[key]
	return v, ok
}

// Pages returns the page count stored under key.
func Pages(key string) (int, error) {
	v, ok := Lookup(key)
	if !ok {
		return 0, fmt.Errorf("no value for %q", key)
	}
	return strconv.Atoi(v)
}

func main() {
	_, err := strconv.Atoi(LookupBad("weight")) // the missing key looks like a parse failure
	fmt.Println("in-band:", err)

	_, err = Pages("weight")
	fmt.Println("explicit:", err)
	n, err := Pages("pages")
	fmt.Println(n, err)
}
in-band: strconv.Atoi: parsing "": invalid syntax
explicit: no value for "weight"
320 <nil>

LookupBad("weight") returns "" for a missing key, and strconv.Atoi then reports a parse failure, pointing the reader at the wrong problem. Lookup returns (string, bool), and Pages turns the missing key into an error that says what was missing. Trying to pass both results of a two-value function to a one-value function is a compile error:

package main

import "strconv"

func Lookup(key string) (string, bool) { return key, true }

func main() {
	strconv.Atoi(Lookup("pages"))
}
# example.com/shelf/badlookup
badlookup/main.go:8:15: too many arguments in call to strconv.Atoi
	have (string, bool)
	want (string)

Passing the results of a two-result call straight to another function is allowed only when the parameter list matches exactly, which strconv.Atoi(string) does not. Not every special value is in-band error signalling: strings.Index returns -1 for "not found" and strings.Cut returns (before, after string, found bool). The standard library has both, and the second form is the recommended one for new code.

Indent error flow is the most visible habit in Go source. Both functions below behave identically; the second is the idiom:

package main

import (
	"errors"
	"fmt"
	"strconv"
)

// pagesNested keeps the happy path indented.
func pagesNested(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err == nil {
		if n > 0 {
			return n, nil
		} else {
			return 0, errors.New("pages must be positive")
		}
	} else {
		return 0, fmt.Errorf("pages: %w", err)
	}
}

// pages handles the failures first and keeps the happy path at the left margin.
func pages(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("pages: %w", err)
	}
	if n <= 0 {
		return 0, errors.New("pages must be positive")
	}
	return n, nil
}

// Stringer check: the compiler verifies the assertion, nothing runs.
type LoanState int

const (
	Unknown LoanState = iota // zero value means "not set"
	Available
	OnLoan
)

func (s LoanState) String() string {
	switch s {
	case Available:
		return "available"
	case OnLoan:
		return "on loan"
	default:
		return "unknown"
	}
}

var _ fmt.Stringer = LoanState(0)

func main() {
	for _, in := range []string{"320", "0", "x"} {
		a, errA := pagesNested(in)
		b, errB := pages(in)
		fmt.Println(a, errA, "|", b, errB)
	}
	var s LoanState
	fmt.Println(s, Available, OnLoan)
}
320 <nil> | 320 <nil>
0 pages must be positive | 0 pages must be positive
0 pages: strconv.Atoi: parsing "x": invalid syntax | 0 pages: strconv.Atoi: parsing "x": invalid syntax
unknown available on loan

pagesNested has an else after every return, so the successful path is buried two levels deep. pages is flat: each failure is dealt with and left behind, and the last line is the result. The program also shows two further idioms: the enum starts with an explicit Unknown zero value, so an unset LoanState is distinguishable from a real one, and var _ fmt.Stringer = LoanState(0) makes the compiler check that the type implements the interface (a compile-time assertion that costs nothing at run time).

Interfaces, types and values

Item What the reviewer asks for Today

Interfaces

Interfaces belong in the package that uses values of the interface type, not in the package that implements them. The implementing package returns concrete types. Do not define an interface "for mocking" on the implementer’s side, and do not define an interface before there is a real use for it.

Unchanged; see Accept interfaces, return structs.

Pass values

Do not pass pointers just to save a few bytes. A pointer to a string or to an interface value is almost always a mistake because both are small fixed-size values. This does not apply to large structs, or to small structs that may grow.

Unchanged; see Pointers are a last resort.

Receiver type

When in doubt use a pointer receiver. Use a pointer when the method mutates the receiver, when the struct contains a mutex or similar, or when it is large. A value receiver suits small, naturally value-like types (time.Time, basic types). Do not mix receiver kinds on one type, and do not choose a value receiver for allocation reasons without profiling.

Unchanged; go vet (copylocks) flags the mutex copy.

Copying

Be careful copying a struct from another package: it may contain a slice that aliases the original (a bytes.Buffer), or a pointer-receiver type whose copy misbehaves.

Unchanged.

Declaring empty slices

Prefer var t []string (a nil slice) to t := []string{}. Do not make an API behave differently for nil and empty slices. The exception is JSON, where nil encodes as null and empty as [].

Unchanged; see the example below.

Crypto rand

Never use math/rand (or math/rand/v2) to generate keys, even throwaway ones. Use crypto/rand, or crypto/rand.Text for a string.

crypto/rand.Text exists since Go 1.24; see below.

Receiver kind is the item that produces a go vet report. A value receiver on a type that contains a mutex copies the lock on every call, and so does an assignment:

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c Counter) Value() int { return c.n } // value receiver copies the mutex

func (c *Counter) Inc() {
	c.mu.Lock()
	c.n++
	c.mu.Unlock()
}

func main() {
	var c Counter
	c.Inc()
	cp := c
	fmt.Println(c.Value(), cp.Value())
}
go vet ./recv
recv/main.go:13:9: Value passes lock by value: example.com/shelf/recv.Counter contains sync.Mutex
recv/main.go:24:8: assignment copies lock value to cp: example.com/shelf/recv.Counter contains sync.Mutex

Both reports come from the copylocks analyzer. The fix is a pointer receiver on Value (and never copying a Counter). The program still compiles and prints 1 1, because the copy happens to be taken when the mutex is unlocked; that is exactly why this is a review item and not only a bug.

A nil slice is the correct empty value, with one visible exception:

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	var a []string  // nil slice: preferred
	b := []string{} // non-nil, empty
	fmt.Println(len(a), cap(a), a == nil, len(b), cap(b), b == nil)
	ja, _ := json.Marshal(a)
	jb, _ := json.Marshal(b)
	fmt.Println(string(ja), string(jb))
	a = append(a, "ok") // appending to nil works
	fmt.Println(a)
}
0 0 true 0 0 false
null []
[ok]

len, cap, range and append treat both forms alike. Only comparison with nil and JSON encoding tell them apart, which is why an API should not give the two forms different meanings, and why a handler that must send [] creates the slice with make or a literal.

Keys, tokens and identifiers that must be unguessable come from crypto/rand. Since Go 1.24 crypto/rand.Text returns a ready-to-use random string, so there is no need for an encoding step:

package main

import (
	"crypto/rand"
	"fmt"
)

// SessionToken returns an unguessable token: 26 base32 characters, 128 bits.
func SessionToken() string { return rand.Text() }

func main() {
	a, b := SessionToken(), SessionToken()
	fmt.Println(len(a), a != b)
}
26 true

Text returns 26 characters of the RFC 4648 base32 alphabet, which is 128 bits of randomness; the program prints only the length and a comparison because the value is different on every run. math/rand/v2 is the right package for simulations, shuffles and tests, and the wrong one for anything an attacker would like to predict. See Crypto and Security.

Imports, goroutines and tests

Item What the reviewer asks for Today

Imports

Rename an import only to avoid a collision (rename the most local one). Group imports with blank lines, standard library first.

goimports and gopls do the grouping. Many teams add a third group for the module’s own packages.

Import blank

import _ "pkg" for side effects belongs only in package main or in tests that need it.

Unchanged; this is also why a library must not register itself with a global in init.

Import dot

import . is acceptable only in an external test package that cannot import the package under test because of an import cycle. Nowhere else.

Unchanged.

Contexts

ctx context.Context is the first parameter of functions that use one. Do not store a context in a struct, do not create custom context types, and do not pass application data in a context when a parameter would do.

Unchanged; go vet (lostcancel) and staticcheck help. See Context first.

Goroutine lifetimes

Every goroutine you start has an obvious end, or a comment saying when and why it ends. Leaked goroutines block forever, sends on closed channels panic, and in-flight work keeps using inputs the caller thinks it has finished with.

testing/synctest (Go 1.25) and the goroutine-leak profile (generally available in Go 1.27) find leaks; see Keep APIs free of hidden concurrency.

Synchronous functions

Prefer functions that return their result directly. The caller can always add concurrency; it cannot remove it.

Unchanged; see Keep APIs free of hidden concurrency.

Useful test failures

A failing test says what was wrong, for which input, what it got and what it wanted, in that order (got before want). Prefer table-driven tests to a pile of assert helpers.

Unchanged; see Testing idioms.

The import grouping and the dot-import rule are mechanical. The test rule is easiest to see in a failing run, which is shown after the next section.

Idioms from the books and the community

The same few idioms occur in the Go books (Bodner chapters 5 to 12 teach most of them), in the Google and Uber guides and in the Go Proverbs. They are opinions with reasons, so each subsection gives the rule, the reason, an example from the shelf catalogue, and the exceptions.

Make the zero value useful

A type is easiest to use when var x T already works. The standard library does this everywhere: sync.Mutex, sync.WaitGroup, bytes.Buffer, strings.Builder and http.Server can be used without a constructor. The benefits are fewer invalid states, no New function to forget, and structs that embed such a type become usable too.

package main

import (
	"fmt"
	"sort"
	"strings"
	"sync"
)

// Shelf is a set of titles. The zero value is an empty shelf ready to use.
type Shelf struct {
	mu     sync.Mutex
	titles map[string]struct{}
}

// Add puts a title on the shelf.
func (s *Shelf) Add(title string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.titles == nil {
		s.titles = make(map[string]struct{})
	}
	s.titles[title] = struct{}{}
}

// Titles returns the titles in alphabetical order.
func (s *Shelf) Titles() []string {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]string, 0, len(s.titles))
	for t := range s.titles {
		out = append(out, t)
	}
	sort.Strings(out)
	return out
}

func main() {
	var s Shelf // no constructor needed
	fmt.Println("empty:", s.Titles(), len(s.Titles()))
	s.Add("Dune")
	s.Add("Emma")
	fmt.Println("after Add:", s.Titles())

	// bytes.Buffer and strings.Builder follow the same rule.
	var sb strings.Builder
	sb.WriteString("ready without New")
	fmt.Println(sb.String())

	// A nil map is readable but not writable: the zero value is not always enough.
	var m map[string]int
	fmt.Println(m["x"], len(m))
	defer func() { fmt.Println("recovered:", recover()) }()
	m["x"] = 1
}
empty: [] 0
after Add: [Dune Emma]
ready without New
0 0
recovered: assignment to entry in nil map

Shelf allocates its map lazily inside Add; reading a nil map is fine, writing needs the allocation. The last two lines show the limit of the idiom: a bare map variable is not ready to write, and writing to it panics with the message assignment to entry in nil map. So the zero value is useful when the type’s methods take care of that, and when they cannot, a constructor (NewMemStore in the shelf module) is the honest answer. A type that must hold a mutex should be documented as not copyable (see Interfaces, types and values).

Related practice from the guides: start an iota enum so that the zero value means "unset" (shown in the earlier LoanState example) unless zero is a valid and meaningful member. Uber’s guide recommends starting at one for the same reason; the shelf module’s own catalog.LoanState starts at Available because "available" is the sensible default.

Accept interfaces, return structs

The rule is a shorthand for three review comments: a function should depend on the smallest behaviour it needs (a parameter of interface type), it should hand back a concrete type so new methods can be added without breaking callers, and the interface should be declared by the consumer. The Code Review Comments page says it directly: define the interface in the package that uses it.

package main

import (
	"fmt"
)

// Book is a catalogued book.
type Book struct {
	ISBN, Title string
}

// ---- producer side: returns a concrete type, defines no interface ----

// MemStore is an in-memory store of books.
type MemStore struct{ books map[string]Book }

// NewMemStore returns a store holding the given books.
func NewMemStore(books ...Book) *MemStore {
	m := &MemStore{books: map[string]Book{}}
	for _, b := range books {
		m.books[b.ISBN] = b
	}
	return m
}

// Get returns the book with the given ISBN.
func (m *MemStore) Get(isbn string) (Book, bool) { b, ok := m.books[isbn]; return b, ok }

// Len reports how many books the store holds.
func (m *MemStore) Len() int { return len(m.books) }

// Put adds or replaces a book.
func (m *MemStore) Put(b Book) { m.books[b.ISBN] = b }

// ---- consumer side: declares the one method it needs ----

type bookGetter interface {
	Get(isbn string) (Book, bool)
}

// Titles looks up each ISBN and returns the titles found.
func Titles(src bookGetter, isbns ...string) []string {
	var out []string
	for _, i := range isbns {
		if b, ok := src.Get(i); ok {
			out = append(out, b.Title)
		}
	}
	return out
}

// fakeGetter is a test double; it needs no help from the producer package.
type fakeGetter map[string]string

func (f fakeGetter) Get(isbn string) (Book, bool) {
	t, ok := f[isbn]
	return Book{ISBN: isbn, Title: t}, ok
}

func main() {
	store := NewMemStore(Book{"1", "Dune"}, Book{"2", "Emma"})
	fmt.Println(Titles(store, "1", "3", "2"))
	fmt.Println(Titles(fakeGetter{"9": "Fake"}, "9", "1"))
	fmt.Println(len(Titles(store)), store.Len())
}
[Dune Emma]
[Fake]
0 2

MemStore is the producer. It declares no interface, so adding methods (Len, Put) breaks nobody. Titles is the consumer: it declares bookGetter with the single method it calls, which means any value with a Get method works, including fakeGetter, a test double written in two lines without any help from the producer. This is also why Go interfaces are so small: the standard library’s most useful ones (io.Reader, io.Writer, fmt.Stringer, error, sort.Interface) have one to three methods.

The exceptions are real and common:

  • a function that returns different implementations behind one behaviour returns the interface (hash.Hash from sha256.New, error from every function that fails, io.Reader from wrappers such as io.LimitReader);

  • a factory may return an interface when the concrete type is deliberately unexported;

  • a function that must return nil for "none" returns a pointer to the struct, not the struct;

  • returning a struct never means returning a pointer to a huge one for performance reasons; it means returning the concrete type, pointer or value as the type’s design requires.

The opposite habit, declaring an interface next to every implementation "so it can be mocked", is the second thing the reviewers push back on, because it freezes the method set and guesses at needs nobody has yet. When you do need a fake, declare the small interface in the test file. See Interfaces and Composition and Object-Oriented Design.

Pointers are a last resort

Bodner’s advice for beginners coming from Java or C# is to start with values and add pointers only when there is a reason. The reasons that qualify:

  • the function or method must change the caller’s value (a pointer receiver, or a pointer parameter such as json.Unmarshal(data, &v));

  • the value is large enough that copying matters (measure first);

  • the value contains something that must not be copied (a mutex, a sync.WaitGroup, a strings.Builder after first use);

  • nil must be a meaningful "no value" (an optional field), as opposed to an invalid one.

The reasons that do not: "to avoid copying a string" (a string is two words), "because that is what the other language does" and "so the function can return nil on error" (return an error). Pointers also have costs: they can be nil, they make escape analysis keep values on the heap (see Memory Management and Garbage Collection), and they make it harder to see who can modify a value.

The most common misuse is the pointer to an interface. An interface value already is a small header that can hold a pointer, so *io.Reader is almost never what you want, and the compiler says so:

package main

import (
	"fmt"
	"io"
	"strings"
)

func drain(r *io.Reader) int {
	b, _ := io.ReadAll(r)
	return len(b)
}

func main() {
	var r io.Reader = strings.NewReader("abc")
	fmt.Println(drain(&r))
}
# example.com/shelf/ptriface
ptriface/main.go:10:21: cannot use r (variable of type *io.Reader) as io.Reader value in argument to io.ReadAll: *io.Reader does not implement io.Reader (type *io.Reader is pointer to interface, not interface)

The fix is to take io.Reader and call drain(r) with the interface value itself. Pointers to maps, slices and channels are equally unusual: a map, a channel and a function value are already references, and a slice header is passed by value deliberately (when a method must grow a slice, return the new slice, as append does).

Go 1.26 also made one old pointer chore unnecessary: new now accepts an expression, so p := new(30) or new(time.Now()) creates a pointer to an initialised value, replacing the one-line helper that older code defined to take the address of a literal.

See Pointers and Value Semantics for the full rules, and for comparison the C Reference on pointers, where the same decision is made without a garbage collector.

Never use naked returns

A naked return returns whatever the named results currently hold. Bodner’s rule is to avoid it entirely, the Google guide allows it only in small functions, and Uber and the Code Review Comments page do not recommend it. The danger is a shadowed result:

package main

import (
	"fmt"
	"strconv"
)

func parse(s string) (n int, err error) {
	if s != "" {
		n, err := strconv.Atoi(s)
		if err != nil {
			return
		}
		_ = n
	}
	return
}

func main() { fmt.Println(parse("x")) }
# example.com/shelf/nakedbad
nakedbad/main.go:12:4: result parameter n not in scope at return
	nakedbad/main.go:10:3: inner declaration of var n int
nakedbad/main.go:12:4: result parameter err not in scope at return
	nakedbad/main.go:10:6: inner declaration of var err error

The inner n, err := strconv.Atoi(s) declares new variables, so the return inside the if would return the outer results unchanged, and the compiler rejects it. In real code the shadowing is often subtler (a variable declared in an if header) and nothing complains. The rule of thumb:

  • naming results is fine for documentation ((lo, hi int)), and necessary when a deferred function must change the result, such as annotating an error;

  • in any function longer than a few lines, write the values in the return statement;

  • never name results merely to avoid declaring a variable.

Named results used together with defer are an idiom, not a style problem; see Functions, Closures and Defer and Error Handling.

Keep APIs free of hidden concurrency

A function that starts a goroutine and returns a channel decides for the caller how the work is scheduled, when it stops and who cleans up. A synchronous function leaves all of those decisions to the caller, who can add concurrency in one line and cannot take it away.

package main

import (
	"fmt"
	"sort"
	"sync"
	"time"
)

// FetchTitle is synchronous: it returns when the work is done.
func FetchTitle(isbn string) (string, error) {
	time.Sleep(10 * time.Millisecond) // pretend network
	return "title-of-" + isbn, nil
}

// FetchTitleAsync hides a goroutine and a channel; the caller cannot cancel it,
// cannot tell when it is done unless it reads the channel, and leaks if it does not.
func FetchTitleAsync(isbn string) <-chan string {
	ch := make(chan string) // unbuffered: the goroutine blocks until someone receives
	go func() {
		t, _ := FetchTitle(isbn)
		ch <- t
	}()
	return ch
}

func main() {
	// The caller decides how much concurrency it wants.
	isbns := []string{"a", "b", "c"}
	var (
		mu     sync.Mutex
		titles []string
		wg     sync.WaitGroup
	)
	for _, i := range isbns {
		wg.Go(func() {
			t, err := FetchTitle(i)
			if err != nil {
				return
			}
			mu.Lock()
			titles = append(titles, t)
			mu.Unlock()
		})
	}
	wg.Wait()
	sort.Strings(titles)
	fmt.Println(titles)

	fmt.Println(<-FetchTitleAsync("z"))
}
[title-of-a title-of-b title-of-c]
title-of-z

FetchTitle is synchronous and tells the caller everything: the result, the error, and that it is finished when it returns. The three calls run concurrently because the caller chose to wrap them in wg.Go (Go 1.25). FetchTitleAsync hides a goroutine behind an unbuffered channel; if the caller never receives, that goroutine blocks forever, and nothing in the signature offers cancellation or an error. Forms of the same trap: returning a channel that someone must drain, starting a background goroutine in a constructor with no Close, and callbacks invoked on an unspecified goroutine.

When concurrency is the feature (a server, a pipeline), the API says so, accepts a context.Context and gives the caller a way to wait: a Run(ctx) that blocks until the context is cancelled, or a Close that stops the workers and returns. A goroutine whose lifetime is bound to a context can be tested for a clean exit:

package main

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

// watchLeaky starts a goroutine nobody can stop.
func watchLeaky(events <-chan string) {
	go func() {
		for e := range events { // exits only if events is closed
			_ = e
		}
	}()
}

// watch ties the goroutine's lifetime to ctx and returns a channel that is closed when it has exited.
func watch(ctx context.Context, events <-chan string) <-chan struct{} {
	done := make(chan struct{})
	go func() {
		defer close(done)
		for {
			select {
			case <-ctx.Done():
				return
			case _, ok := <-events:
				if !ok {
					return
				}
			}
		}
	}()
	return done
}

func main() {
	base := runtime.NumGoroutine()
	for range 5 {
		watchLeaky(make(chan string))
	}
	time.Sleep(20 * time.Millisecond)
	fmt.Println("leaked:", runtime.NumGoroutine()-base)

	ctx, cancel := context.WithCancel(context.Background())
	done := watch(ctx, make(chan string))
	cancel()
	<-done
	fmt.Println("stopped cleanly")
}
leaked: 5
stopped cleanly

watchLeaky starts five goroutines on channels nobody closes; five goroutines remain, which runtime.NumGoroutine shows (the number is for this run; the point is that it equals the number of leaked calls). watch returns a done channel that is closed when the goroutine exits, and it exits when the context is cancelled or the input channel closes. This is the shape recommended by the Code Review Comments page: make clear when, or whether, goroutines exit. See Concurrency Patterns, Context and Goroutines and the Scheduler.

Avoid init

init functions run automatically when a package is initialised, in dependency order, before main. They cannot take arguments, cannot return an error, cannot be called again, and run for every importer, including programs that do not use the feature. The guides (Uber says "avoid `init()`", Bodner says to avoid it except for registering drivers) give reasons that are easy to see:

package main

import (
	"fmt"
	"os"
)

var dir string

// init runs before main, cannot return an error, cannot be called from a test
// with other inputs and hides a dependency on the environment.
func init() {
	dir = os.Getenv("SHELF_DIR")
	if dir == "" {
		dir = "/var/lib/shelf"
	}
}

// Config is what init should have been: explicit and testable.
type Config struct{ Dir string }

// ConfigFromEnv builds a Config from a lookup function.
func ConfigFromEnv(getenv func(string) string) (Config, error) {
	d := getenv("SHELF_DIR")
	if d == "" {
		return Config{}, fmt.Errorf("SHELF_DIR is not set")
	}
	return Config{Dir: d}, nil
}

func main() {
	fmt.Println("init chose:", dir)
	_, err := ConfigFromEnv(func(string) string { return "" })
	fmt.Println("explicit:", err)
	cfg, _ := ConfigFromEnv(func(k string) string { return map[string]string{"SHELF_DIR": "/srv/shelf"}[k] })
	fmt.Println(cfg.Dir)
}
init chose: /var/lib/shelf
explicit: SHELF_DIR is not set
/srv/shelf

The init version reads the environment on import and silently falls back to a default path; a test cannot exercise a different environment without setting variables before the process starts, and the failure case (a missing variable) cannot be reported. ConfigFromEnv takes the lookup function as a parameter, returns an error and is trivially testable. The same applies to package-level variables initialised with function calls that can fail or depend on global state.

Legitimate uses are narrow: registering a driver or codec with a registry in a package that exists only for that purpose (database/sql drivers, image/png), precomputing a table, and a handful of other things that cannot fail. The corresponding Code Review Comments rule is that a blank import belongs only in main or in tests. Initialisation order across package-level variables and several init functions is specified; see Packages, Imports and Initialization.

Do not rely on DefaultServeMux and DefaultClient

net/http offers convenient package-level values: http.Get and http.DefaultClient, http.Handle, http.HandleFunc and http.DefaultServeMux, and http.ListenAndServe(addr, nil). Bodner’s advice is never to use them in production code, and the reasons have not changed:

  • http.DefaultClient has no timeout, so a server that stops answering stalls the caller indefinitely; http.Get uses it;

  • http.DefaultServeMux is a process-wide global that any imported package can register handlers on: importing net/http/pprof or expvar publishes /debug/pprof/ and /debug/vars on it, so a server started with a nil handler exposes them without anyone writing the line;

  • http.ListenAndServe creates a Server with no read, write or idle timeouts, so slow clients can hold connections open.

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

func main() {
	slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-time.After(2 * time.Second):
		case <-r.Context().Done():
		}
		fmt.Fprintln(w, "late")
	}))
	defer slow.Close()

	// http.DefaultClient has no timeout; a context bounds this one call,
	// but the client itself would wait forever without it.
	fmt.Println("DefaultClient.Timeout:", http.DefaultClient.Timeout)

	// A purpose-built client carries its own limit.
	client := &http.Client{Timeout: 100 * time.Millisecond}
	_, err := client.Get(slow.URL)
	var timeout interface{ Timeout() bool }
	fmt.Println("timed out:", errors.As(err, &timeout) && timeout.Timeout())
	fmt.Println(errors.Is(err, context.DeadlineExceeded))

	// A private mux instead of http.DefaultServeMux.
	mux := http.NewServeMux()
	mux.HandleFunc("GET /books/{isbn}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "book %s\n", r.PathValue("isbn"))
	})
	srv := httptest.NewServer(mux)
	defer srv.Close()
	resp, err := client.Get(srv.URL + "/books/9780306406157")
	if err != nil {
		fmt.Println(err)
		return
	}
	defer resp.Body.Close()
	buf := make([]byte, 64)
	n, _ := resp.Body.Read(buf)
	fmt.Print(string(buf[:n]))

	// Anything registered on the global mux is served by every server that uses nil as handler.
	http.HandleFunc("/debug-leak", func(http.ResponseWriter, *http.Request) {})
	h, pattern := http.DefaultServeMux.Handler(mustReq("/debug-leak"))
	fmt.Println(h != nil, pattern)
}

func mustReq(path string) *http.Request {
	r, err := http.NewRequest(http.MethodGet, "http://example.invalid"+path, nil)
	if err != nil {
		panic(err)
	}
	return r
}
DefaultClient.Timeout: 0s
timed out: true
true
book 9780306406157
true /debug-leak

The first line shows that the default client has a zero timeout. The purpose-built client times out in 100 ms (the Timeout() bool method is the portable way to ask whether an error is a timeout) and is a distinct value you can configure once and share. The private ServeMux is an ordinary value passed to the server, and since Go 1.22 it routes by method and wildcard (GET /books/{isbn}) so the router that older books recommend a third-party library for is built in. The last lines show the hazard: a handler registered with http.HandleFunc is visible on DefaultServeMux, which a nil-handler server serves. For production servers set ReadHeaderTimeout, ReadTimeout, WriteTimeout and IdleTimeout on an explicit http.Server; the shelf service does this, and net/http Clients and Servers covers the details, including CrossOriginProtection (Go 1.25) and, for CORS, the site’s CORS page.

API design for packages

A package is the unit of reuse in Go, and its exported identifiers are its API. Everything below is a consequence of two facts: exported names cannot be removed or changed without breaking importers (the Go 1 compatibility promise for the standard library, semantic versioning for modules), and the compiler and the tools give callers very little help once a bad shape has shipped.

Package names and layout

  • Name the package after what it provides, in one lowercase word: catalog, not catalogutil, common or types. The package name appears at every call site, so catalog.Book reads well and catalog.CatalogBook stutters.

  • Put code that is not part of the public API under internal/; the compiler refuses imports from outside the parent tree. The shelf module’s helper package lives at example.com/shelf/internal/textutil.

  • A module with a command is laid out as cmd/<name>/main.go plus importable packages next to it; the official page Organizing a Go module describes the supported layouts. The widely copied golang-standards/project-layout repository is not an official standard.

  • Keep main thin: parse flags, build dependencies, call a function that returns an error, and exit once, in main (the Uber guide calls this "exit in main"); see Command-Line Programs.

  • Avoid dependency cycles by moving shared types to a lower package, not by creating a package named types.

Small interfaces

An interface of one to three methods can be satisfied by many types, composed (io.ReadWriter), and faked in a test. A large interface forces every implementation and fake to implement what the caller never calls. Two rules follow. First, the consumer defines the interface it needs (see Accept interfaces, return structs). Second, when the producer must offer an interface (a plug-in point), keep it minimal, and add optional capabilities through additional interfaces discovered by type assertion, as io.WriterTo and http.Flusher do, rather than by growing the original.

Functional options

A constructor with many optional settings should not need a dozen parameters or force callers to fill a struct with zero values that mean "default". The two accepted answers are a plain config struct (best when there are few, stable settings and the zero value of each field is a sensible default) and functional options (best when settings are many, may grow, or need validation):

package main

import (
	"errors"
	"fmt"
	"log/slog"
	"os"
	"time"
)

// Client talks to a (hypothetical) catalogue service.
type Client struct {
	baseURL string
	timeout time.Duration
	retries int
	logger  *slog.Logger
}

// Option configures a Client.
type Option func(*Client) error

// WithTimeout sets the per-request timeout; it must be positive.
func WithTimeout(d time.Duration) Option {
	return func(c *Client) error {
		if d <= 0 {
			return errors.New("timeout must be positive")
		}
		c.timeout = d
		return nil
	}
}

// WithRetries sets how many times a failed request is retried.
func WithRetries(n int) Option {
	return func(c *Client) error {
		if n < 0 {
			return fmt.Errorf("retries must not be negative, got %d", n)
		}
		c.retries = n
		return nil
	}
}

// WithLogger sets the logger.
func WithLogger(l *slog.Logger) Option {
	return func(c *Client) error { c.logger = l; return nil }
}

// NewClient returns a Client with defaults overridden by opts.
func NewClient(baseURL string, opts ...Option) (*Client, error) {
	c := &Client{baseURL: baseURL, timeout: 10 * time.Second, logger: slog.Default()}
	for _, opt := range opts {
		if err := opt(c); err != nil {
			return nil, fmt.Errorf("shelf client: %w", err)
		}
	}
	return c, nil
}

// Config is the alternative for a few, stable settings: a plain struct.
type Config struct {
	BaseURL string
	Timeout time.Duration // zero means 10s
	Retries int
}

func main() {
	log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))
	c, err := NewClient("http://127.0.0.1:8080", WithTimeout(2*time.Second), WithRetries(3), WithLogger(log))
	fmt.Println(c.timeout, c.retries, err)
	c.logger.Info("configured", "base", c.baseURL)

	_, err = NewClient("http://127.0.0.1:8080", WithRetries(-1))
	fmt.Println(err)

	d, _ := NewClient("http://127.0.0.1:8080")
	fmt.Println(d.timeout, d.retries)
	_ = Config{BaseURL: "x"}
}
2s 3 <nil>
level=INFO msg=configured base=http://127.0.0.1:8080
shelf client: retries must not be negative, got -1
10s 0

Each Option is a function that modifies the client being built and may return an error, which NewClient wraps with context. Defaults are visible in one place, new options can be added without breaking callers, and call sites read as NewClient(url, WithTimeout(2*time.Second)). The cost is more code and one function per setting; for a handful of fields a documented Config struct (declared at the bottom of the example) is simpler. Bodner presents the struct as the way to simulate named parameters, and the options pattern for libraries that must stay extensible. Do not make options a reflex: an unexported *Client field with no option is not a bug.

Errors are part of the API

Which errors a function can return is as much a part of its contract as its parameters. Callers can only depend on what the documentation promises:

  • export a sentinel (var ErrNotFound = errors.New(...)) when callers need to test for a condition with errors.Is;

  • export an error type when callers need data from the error (*ValidationError with a field name), found with errors.As or, since Go 1.26, errors.AsType;

  • wrap with %w to add context while keeping the sentinel or type reachable, and wrap with %v (or return a new error) to deliberately hide an implementation detail so callers cannot depend on it;

  • never return a pointer-typed nil as an error (see the pitfall in Error Handling);

  • do not log and return the same error: the caller decides (the Uber guide: handle an error once).

package main

import (
	"errors"
	"fmt"
)

// ErrNotFound is part of the API: callers may test for it with errors.Is.
var ErrNotFound = errors.New("catalog: not found")

// ValidationError is part of the API: callers may extract it with errors.AsType.
type ValidationError struct {
	Field, Reason string
}

func (e *ValidationError) Error() string { return "catalog: invalid " + e.Field + ": " + e.Reason }

func find(isbn string) error {
	switch isbn {
	case "":
		return &ValidationError{Field: "isbn", Reason: "empty"}
	case "404":
		return fmt.Errorf("find %q: %w", isbn, ErrNotFound)
	}
	return nil
}

func main() {
	for _, in := range []string{"", "404", "ok"} {
		err := find(in)
		if errors.Is(err, ErrNotFound) {
			fmt.Println("not found:", err)
		} else if ve, ok := errors.AsType[*ValidationError](err); ok {
			fmt.Println("validation:", ve.Field, "-", ve.Reason)
		} else {
			fmt.Println("ok:", err)
		}
	}
}
validation: isbn - empty
not found: find "404": catalog: not found
ok: <nil>

errors.AsType[*ValidationError](err) returns the typed error and a bool without the target variable that errors.As needs. find documents ErrNotFound and *ValidationError as part of its contract; the wrapped message find "404": catalog: not found adds context but keeps errors.Is working. The Go blog post Working with Errors in Go 1.13 is the design rationale.

Context first

A function that can block, call another service or be cancelled takes ctx context.Context as its first parameter and passes it down. It never stores the context in a struct (the lifetime of a request is not the lifetime of the object), and it never takes a custom context type.

package main

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

type Store struct{}

// Find takes the context as its first parameter and never stores it.
func (Store) Find(ctx context.Context, isbn string) (string, error) {
	select {
	case <-time.After(50 * time.Millisecond):
		return "title-of-" + isbn, nil
	case <-ctx.Done():
		return "", fmt.Errorf("find %s: %w", isbn, context.Cause(ctx))
	}
}

func main() {
	var s Store
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
	defer cancel()
	_, err := s.Find(ctx, "123")
	fmt.Println(err, errors.Is(err, context.DeadlineExceeded))
	t, err := s.Find(context.Background(), "123")
	fmt.Println(t, err)
}
find 123: context deadline exceeded true
title-of-123 <nil>

Find selects on ctx.Done() and reports context.Cause(ctx), which is context deadline exceeded here and would be the custom error if the caller used WithCancelCause. The exception noted by the Go team is a method whose signature is fixed by an interface in the standard library (http.Handler gets its context from r.Context()). A struct that keeps a context is documented in the blog post Contexts and structs as a smell, with the few exceptions. go vet's lostcancel analyzer reports a cancel function that is never called. Details are in Context.

Other API habits

  • Constructors are named New (or NewX when the package has several types) and return the concrete type; a New that returns (*T, error) documents when it fails.

  • Exported struct fields are part of the API. Prefer methods or unexported fields plus options when invariants must hold, and use keyed literals (catalog.Book{Title: "Dune"}) in your own code so that adding a field is not a break.

  • Deprecation is a paragraph in the doc comment that starts with Deprecated:; tools such as staticcheck and gopls then flag uses. Keep the old name for the compatibility period.

  • Generics belong in an API when one implementation really serves several types (slices, maps), not as a way to avoid writing an interface. See Generics.

  • Do not return slices or maps owned by the object unless the caller is allowed to change them; copy at the boundary when in doubt (slices.Clone).

  • Configuration from the environment belongs in main; libraries take parameters.

  • Logging is a dependency: accept a *slog.Logger (or use the default) rather than printing; see Structured Logging with slog.

Doc comments in practice

Every exported name needs a doc comment, and go doc shows exactly what the next developer will read. For the documented type in the demonstration package:

package shelfdoc // import "example.com/shelf/docdemo/shelfdoc"

type Loan struct {
	// Member is the borrower's identifier.
	Member string
}
    Loan records a book lent to a member.

func (l *Loan) Return() bool

The comments are sentences that start with the name (Loan records a book..., Return marks the loan...) and the field comment documents the invariant callers need. See Documentation and Doc Comments.

Source: Go Doc Comments

Testing idioms

A test that fails should be understandable by someone who has never seen the code. The Code Review Comments page and the Google guide both ask for the same message shape: the function and its input, then got, then want. The table-driven test is the way to write many cases without copying code:

package tabletest

import "testing"

func TestDigits(t *testing.T) {
	tests := []struct {
		name string
		in   string
		want string
		fail bool
	}{
		{"plain", "9780306406157", "9780306406157", false},
		{"hyphens", "978-0-306-40615-7", "9780306406157", false},
		{"short", "123", "", true},
		{"typo in expectation", "978-0-306-40615-7", "9780306406158", false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := Digits(tt.in)
			if (err != nil) != tt.fail {
				t.Fatalf("Digits(%q) error = %v; want error: %v", tt.in, err, tt.fail)
			}
			if got != tt.want {
				t.Errorf("Digits(%q) = %q; want %q", tt.in, got, tt.want)
			}
		})
	}
}
=== RUN   TestDigits
=== RUN   TestDigits/plain
=== RUN   TestDigits/hyphens
=== RUN   TestDigits/short
=== RUN   TestDigits/typo_in_expectation
    isbn_test.go:24: Digits("978-0-306-40615-7") = "9780306406157"; want "9780306406158"
--- FAIL: TestDigits (0.00s)
    --- PASS: TestDigits/plain (0.00s)
    --- PASS: TestDigits/hyphens (0.00s)
    --- PASS: TestDigits/short (0.00s)
    --- FAIL: TestDigits/typo_in_expectation (0.00s)
FAIL
FAIL	example.com/shelf/tabletest	0.064s
FAIL

The failing case reports Digits("978-0-306-40615-7") = "9780306406157"; want "9780306406158": the call, the actual value and the expected one, in that order. t.Fatalf is used when the rest of the test cannot continue (a wrong error state), t.Errorf when it can. Subtests (t.Run) give each case a name that appears in the output and can be selected with -run. Google’s guide goes further and discourages "assertion libraries" because they separate the check from the failure message; the community go-cmp package is recommended for comparing structures and printing a diff. See Testing and Benchmarks, Fuzzing and synctest.

The community guides

Two companies publish their Go style guides, and the Go Proverbs summarise a talk. They are not official, but they are widely read, and a reviewer should know where each stands.

The Google guide

The Google Go Style Guide (https://google.github.io/styleguide/go/) is three documents: the guide (normative: the principles from the earlier section and the core rules), decisions (normative for readability reviewers at Google, with reasoning) and best practices (not normative). Its parts worth knowing:

  • Naming: no underscores except in test names and generated code; package names are short and lowercase; names do not repeat what the package, the receiver or the type already says; function names that return something are nouns, and functions that do something are verbs.

  • Errors: use %w at the end of the format string when callers should be able to unwrap; otherwise %v; test error semantics (with errors.Is) and not error strings.

  • Pass values and receiver type repeat the Code Review Comments.

  • Must functions may fail the program only at initialisation and in tests.

  • Use %q when printing strings that may be empty or contain spaces, so the boundary is visible.

  • Use any instead of interface{}.

  • Naked returns only in small functions; avoid assertion libraries; got before want in test messages.

Its main difference in tone from the wiki page is explicit reasoning for each rule, and its explicit ranking of the principles. It treats consistency inside a package or file as more important than any of the individual rules.

The Uber guide

The Uber Go Style Guide (https://github.com/uber-go/guide) is shorter and written as a list of "bad" and "good" snippets. Its recurring points: verify interface compliance at compile time (var _ Store = (*MemStore)(nil), as in the shelf module), zero-value mutexes are valid (do not allocate one), copy slices and maps at API boundaries, defer to clean up, channels have size one or none, start enums at one, use time.Time and time.Duration instead of bare integers, error types and wrapping, handle each error once, handle failed type assertions with the two-value form, do not panic, avoid mutable globals, avoid embedding types in public structs, avoid built-in names as identifiers, avoid init(), exit in main, do not fire and forget goroutines, prefer strconv over fmt for number conversion, specify container capacity, prefer raw string literals, use field names to initialise structs, and use test tables and functional options.

Some Uber items have aged. The recommendation to use the go.uber.org/atomic package assumed that the standard library’s sync/atomic had no typed values; since Go 1.19 atomic.Int64, atomic.Bool, atomic.Pointer[T] and the other typed atomics exist, and the go fix modernizer atomictypes rewrites old function-style calls. The guide’s advice on "start enums at one" and "channel size is one or none" is a team convention, not a language rule.

Go Proverbs

The proverbs are a mnemonic list, and each one is explained somewhere on this site rather than needing its own rule:

Theme of the proverb Where this reference explains it

share memory by communicating, channels orchestrate and mutexes serialize

Channels and Select, Sync and Atomics

concurrency is not parallelism

Goroutines and the Scheduler

the bigger the interface, the weaker the abstraction

Small interfaces

make the zero value useful

Make the zero value useful

the empty interface says nothing

Interfaces, Generics

gofmt is nobody’s favourite and everybody’s favourite

Formatting, vet, fix and Static Analysis

a little copying is better than a little dependency

Modules and Dependency Management

cgo, unsafe and reflection give up guarantees

cgo and C Interoperability, unsafe and Low-Level Programming, Reflection

clear is better than clever

the Google principles above

errors are values, handle them gracefully

Error Handling

design the architecture, name the components, document the details; documentation is for users

Documentation and Doc Comments

do not panic

Errors and control flow

They are compressed advice from 2015 and carry no version information: the proverb about the empty interface predates any, and the one about dependencies predates the module proxy and checksum database.

Tools that enforce style

A good part of a review can be delegated to tools, which is faster and removes personal taste from the discussion.

Tool What it enforces Related review item

gofmt -l -d -s

Layout, and -s simplifications.

Gofmt, line length

go vet

Printf verbs, copied locks, lost cancel functions, unreachable code, struct tags, loop-variable capture before Go 1.22, testing mistakes.

Receiver type, contexts, useful test failures

go fix

Modernizers (any, rangeint, minmax, errorsastype, waitgroupgo and others); -diff previews them.

Out-of-date idioms

staticcheck (community)

Deprecated APIs, ineffective code, error-string style, unused results, many simplifications.

Error strings, handle errors

golangci-lint (community)

Runs staticcheck, revive, errcheck and others from one configuration.

Whole checklist

gopls

Editor diagnostics, organise imports, rename, the same analyzers as vet.

Imports, naming

The gofmt rule shows what "do not argue about formatting" looks like. The unformatted file below is reduced by gofmt -d -s to the diff that follows (the -s flag also simplified xs[1:len(xs)] to xs[1:]):

diff fmtdemo/main.go.orig fmtdemo/main.go
--- fmtdemo/main.go.orig
+++ fmtdemo/main.go
@@ -1,8 +1,14 @@
 package main
-import ("fmt"
-"os")
+
+import (
+	"fmt"
+	"os"
+)
+
 func main() {
-	var xs []int = []int{1,2,3}
-	if len(xs)>0 { fmt.Println(xs[1:len(xs)]) }
-	os.Exit( 0 )
+	var xs []int = []int{1, 2, 3}
+	if len(xs) > 0 {
+		fmt.Println(xs[1:])
+	}
+	os.Exit(0)
 }

Neither gofmt nor go vet replaces a reviewer’s judgement about names, API shape or concurrency. The Go team’s own advice is to make the mechanical points automatic, so that review time goes into the points on the checklist below. The tools and their flags are described in Formatting, vet, fix and Static Analysis.

A code review checklist

A short list that covers the above, in the order a reviewer usually meets the issues. Most items are one-line questions.

Area Question Reference

Build

Does gofmt -l print nothing, go vet ./... pass, and do the tests pass with -race?

Tools that enforce style

Names

Does a name repeat its package? Are initialisms consistent? Is there a Get prefix, an underscore or ALL_CAPS?

Formatting, comments and naming

Docs

Does every exported name have a sentence-style doc comment? Does the package have a package comment?

Doc comments in practice

Errors

Is every error handled once, wrapped with context, not capitalised, and tested with errors.Is or errors.AsType? Does the happy path stay at the left margin?

Errors and control flow

Values

Could a pointer parameter be a value? Is there a pointer to an interface or a slice? Do receivers mix kinds?

Pointers are a last resort, Interfaces, types and values

Interfaces

Is the interface declared by its consumer, with as few methods as possible? Does the producer return a concrete type?

Accept interfaces, return structs

Concurrency

Does every goroutine have a stated end? Does the API accept a context first and stay synchronous?

Keep APIs free of hidden concurrency, Context first

Initialisation

Is there an init, a package-level variable computed from the environment, or a hidden global?

Avoid init

HTTP

Is a DefaultClient, http.Get or DefaultServeMux used in production code? Does the server set timeouts?

Do not rely on DefaultServeMux and DefaultClient

Randomness

Is math/rand used for anything secret?

Interfaces, types and values

Tests

Is the test table-driven with a message of the form call, got, want? Is any time.Sleep really needed (see synctest)?

Testing idioms

API

Which names become exported, and can this change be made without breaking importers? Is the new option or error documented?

API design for packages

When to break the rules

Every guide on this page says that its rules are defaults. The reasons to depart from them are specific:

  • A measured performance need. A pointer receiver or pointer parameter for a large struct, a value receiver for an allocation-sensitive hot path, a sized make after profiling. Measure first (see Performance and PGO).

  • Interop with another language or a generated API. Names generated by protoc or cgo do not follow the initialism rule, and //go:generate output is exempt from review style.

  • Compatibility. An exported name that already breaks a rule stays until the next major version.

  • Local consistency. A package that already uses the other convention keeps it unless the whole package is changed in one commit.

  • A clear reason stated in a comment. "Returns an interface because several implementations are returned" is a fine comment; "this is how I like it" is not.

A reviewer who blocks a change should be able to name the reason (readability, safety, compatibility, performance) and, if possible, the tool or document that says so. A comment of the form "the wiki says X" is weaker than a comment that explains the failure that X prevents.

Where the books are outdated

The idioms in Bodner and Vivien are sound; the tooling and some library advice around them have changed.

Topic What the books (written for Go 1.15 and earlier) say Current approach

Linting

Run the lint tool of the time (retired and archived) to find style problems.

go vet and gopls analyzers, plus the community staticcheck and golangci-lint. The Go team no longer maintains a style linter; the Code Review Comments page remains the reference.

Randomness

math/rand has a fixed default seed; seed it yourself.

It is seeded randomly since Go 1.20, and math/rand/v2 (Go 1.22) is the modern API. Secrets still need crypto/rand (rand.Text, Go 1.24).

HTTP routing

The standard ServeMux cannot match methods or path variables; use a third-party router.

Method and wildcard patterns (GET /books/{isbn}) and Request.PathValue since Go 1.22.

Loop variables

Copy the loop variable inside the loop before using it in a closure or goroutine.

Per-iteration variables since Go 1.22 (modules declaring go 1.22 or later); the forvar modernizer removes the copies.

Pointer to a literal

Define a helper function to take the address of a literal.

new(expr) since Go 1.26.

Concurrency helpers

Use sync.WaitGroup with Add and Done; no context, no errgroup in the older book.

context (Go 1.7), sync.WaitGroup.Go (Go 1.25), x/sync/errgroup, testing/synctest (Go 1.25).

Error inspection

Compare errors with == and type-assert them.

%w, errors.Is, errors.As (Go 1.13), errors.Join (Go 1.20) and errors.AsType (Go 1.26).

Typed atomics

Function-style atomic.AddInt64; the Uber guide recommends a third-party package.

Typed atomics in sync/atomic since Go 1.19.

Generic code

interface{} plus assertions, or code generation.

Type parameters (Go 1.18) and the slices, maps and cmp packages.

JSON

Only encoding/json v1.

encoding/json/v2 and jsontext are generally available in Go 1.27; see encoding/json.

Logging

log only.

log/slog since Go 1.21.

Two errata in the books are worth stating because they touch idioms: a pointer is not needed to make a method "faster" on a small value (the compiler decides, and the rule is "measure first"), and the empty interface is not generic code. The full list of outdated statements is collected in What’s New and Migrating Older Code.

Version notes

  • Go 1.7: context in the standard library, which makes "context first" possible.

  • Go 1.13: %w, errors.Is, errors.As and errors.Unwrap.

  • Go 1.18: generics.

  • Go 1.19: typed atomics; doc comment syntax with headings, lists and links.

  • Go 1.20: errors.Join; math/rand seeded automatically.

  • Go 1.21: min, max, clear, log/slog.

  • Go 1.22: per-iteration loop variables; ServeMux patterns with methods and wildcards; math/rand/v2; for range n.

  • Go 1.23: range over functions.

  • Go 1.24: crypto/rand.Text; omitzero for JSON; testing.B.Loop.

  • Go 1.25: sync.WaitGroup.Go; testing/synctest generally available; net/http.CrossOriginProtection.

  • Go 1.26: new(expr); errors.AsType; go fix rebuilt around modernizers.

  • Go 1.27: this page was verified with go 1.27 in go.mod; the style rules themselves are unchanged. Go 1.28 is not released at the time of writing (expected in February 2027).