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 language definition. It says what is legal, never what is good. |
Final word on semantics. It contains no style rules. |
|
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, |
Still the best description of the core ideas (names, composition, interfaces, goroutines and channels, errors). Treat every library or tooling statement as possibly dated. |
|
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. |
|
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 |
|---|---|---|
Let a tool format the code; do not argue about layout. |
Run |
|
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. |
|
The package name is part of every use ( |
Nothing important. Generic type parameters add short single-letter names by convention. |
|
Drop the |
|
|
Multiple results, named results for documentation and for |
Nothing important; read it together with the guidance on naked returns below. |
|
|
|
|
Constants and |
Prefer explicit construction to |
|
Pointer versus value receivers. |
Unchanged; the receiver rules are repeated in Interfaces, types and values. |
|
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. |
|
Composition by embedding; the embedded type’s methods are promoted. |
Unchanged; see Structs and Embedding. |
|
Share memory by communicating; goroutines are cheap; channels of channels; |
|
|
Errors are values; return them, add context, |
Wrapping with |
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 |
|---|---|---|
Run |
Format on save in the editor and in CI ( |
|
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. |
|
Every exported name has a doc comment, and so does every non-trivial unexported type or function. |
Unchanged; |
|
The package comment sits directly above the |
Unchanged. |
|
A new package comes with a runnable |
Testable examples are verified by |
|
Multi-word names use |
Unchanged: an unexported constant is |
|
Initialisms keep one case: |
Unchanged. |
|
Do not repeat the package name in identifiers ( |
Unchanged; see Package names and layout. |
|
Use one or two letters derived from the type ( |
Unchanged. |
|
Names get shorter the nearer their use is to their declaration: |
Unchanged. |
|
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 |
|---|---|---|
|
|
Callers already wrote |
|
|
The package name carries the noun. |
|
|
The function belongs with the type it validates; a package called |
|
|
Stutter again. |
|
|
Getters have no |
|
|
Initialisms keep one case. |
|
|
The receiver is an ordinary parameter, named after the type. |
Errors and control flow
| Item | What the reviewer asks for | Today |
|---|---|---|
Never discard an error with |
|
|
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. |
|
Do not signal failure with a special value such as |
Unchanged; see the example below. |
|
Handle the error first and |
Unchanged; the linters |
|
Use |
Unchanged. |
|
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. |
|
A |
Unchanged; |
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 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. |
|
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. |
|
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 ( |
Unchanged; |
|
Be careful copying a struct from another package: it may contain a slice that aliases the original (a
|
Unchanged. |
|
Prefer |
Unchanged; see the example below. |
|
Never use |
|
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.
Source: Go Code Review Comments: Receiver Type and cmd/vet
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.
Source: crypto/rand.Text and Go 1.24 release notes
Imports, goroutines and tests
| Item | What the reviewer asks for | Today |
|---|---|---|
Rename an import only to avoid a collision (rename the most local one). Group imports with blank lines, standard library first. |
|
|
|
Unchanged; this is also why a library must not register itself with a global in |
|
|
Unchanged. |
|
|
Unchanged; |
|
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. |
|
|
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. |
|
A failing test says what was wrong, for which input, what it got and what it wanted, in that order ( |
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).
Source: Effective Go: Allocation with new and Go Proverbs (community)
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.Hashfromsha256.New,errorfrom every function that fails,io.Readerfrom wrappers such asio.LimitReader); -
a factory may return an interface when the concrete type is deliberately unexported;
-
a function that must return
nilfor "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, astrings.Builderafter first use); -
nilmust 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.
Source: Go Code Review Comments: Pass Values, Language specification: Allocation and Go 1.26 release notes
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
returnstatement; -
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.
Source: Language specification: Package initialization and Uber Go Style Guide (community)
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.DefaultClienthas no timeout, so a server that stops answering stalls the caller indefinitely;http.Getuses it; -
http.DefaultServeMuxis a process-wide global that any imported package can register handlers on: importingnet/http/pproforexpvarpublishes/debug/pprof/and/debug/varson it, so a server started with anilhandler exposes them without anyone writing the line; -
http.ListenAndServecreates aServerwith 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, notcatalogutil,commonortypes. The package name appears at every call site, socatalog.Bookreads well andcatalog.CatalogBookstutters. -
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 atexample.com/shelf/internal/textutil. -
A module with a command is laid out as
cmd/<name>/main.goplus importable packages next to it; the official page Organizing a Go module describes the supported layouts. The widely copiedgolang-standards/project-layoutrepository is not an official standard. -
Keep
mainthin: parse flags, build dependencies, call a function that returns anerror, and exit once, inmain(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.
Source: Uber Go Style Guide: Functional Options (community) and Effective Go: Composite literals
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 witherrors.Is; -
export an error type when callers need data from the error (
*ValidationErrorwith a field name), found witherrors.Asor, since Go 1.26,errors.AsType; -
wrap with
%wto 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.
Source: errors.AsType and Working with Errors in Go 1.13
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(orNewXwhen the package has several types) and return the concrete type; aNewthat 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 asstaticcheckandgoplsthen 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
%wat the end of the format string when callers should be able to unwrap; otherwise%v; test error semantics (witherrors.Is) and not error strings. -
Pass values and receiver type repeat the Code Review Comments.
-
Mustfunctions may fail the program only at initialisation and in tests. -
Use
%qwhen printing strings that may be empty or contain spaces, so the boundary is visible. -
Use
anyinstead ofinterface{}. -
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 |
|
concurrency is not parallelism |
|
the bigger the interface, the weaker the abstraction |
|
make the zero value useful |
|
the empty interface says nothing |
|
gofmt is nobody’s favourite and everybody’s favourite |
|
a little copying is better than a little dependency |
|
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 |
|
design the architecture, name the components, document the details; documentation is for users |
|
do not panic |
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 |
|---|---|---|
|
Layout, and |
Gofmt, line length |
|
Printf verbs, copied locks, lost cancel functions, unreachable code, struct tags, loop-variable capture before
Go 1.22, |
Receiver type, contexts, useful test failures |
|
Modernizers ( |
Out-of-date idioms |
|
Deprecated APIs, ineffective code, error-string style, unused results, many simplifications. |
Error strings, handle errors |
|
Runs |
Whole checklist |
|
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.
Source: cmd/gofmt, cmd/vet and Using go fix to modernize Go code
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 |
|
Names |
Does a name repeat its package? Are initialisms consistent? Is there a |
|
Docs |
Does every exported name have a sentence-style doc comment? Does the package have a package comment? |
|
Errors |
Is every error handled once, wrapped with context, not capitalised, and tested with |
|
Values |
Could a pointer parameter be a value? Is there a pointer to an interface or a slice? Do receivers mix kinds? |
|
Interfaces |
Is the interface declared by its consumer, with as few methods as possible? Does the producer return a concrete type? |
|
Concurrency |
Does every goroutine have a stated end? Does the API accept a context first and stay synchronous? |
|
Initialisation |
Is there an |
|
HTTP |
Is a |
|
Randomness |
Is |
|
Tests |
Is the test table-driven with a message of the form call, got, want? Is any |
|
API |
Which names become exported, and can this change be made without breaking importers? Is the new option or error documented? |
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
makeafter profiling. Measure first (see Performance and PGO). -
Interop with another language or a generated API. Names generated by
protocorcgodo not follow the initialism rule, and//go:generateoutput 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. |
|
Randomness |
|
It is seeded randomly since Go 1.20, and |
HTTP routing |
The standard |
Method and wildcard patterns ( |
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 |
Pointer to a literal |
Define a helper function to take the address of a literal. |
|
Concurrency helpers |
Use |
|
Error inspection |
Compare errors with |
|
Typed atomics |
Function-style |
Typed atomics in |
Generic code |
|
Type parameters (Go 1.18) and the |
JSON |
Only |
|
Logging |
|
|
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:
contextin the standard library, which makes "context first" possible. -
Go 1.13:
%w,errors.Is,errors.Asanderrors.Unwrap. -
Go 1.18: generics.
-
Go 1.19: typed atomics; doc comment syntax with headings, lists and links.
-
Go 1.20:
errors.Join;math/randseeded automatically. -
Go 1.21:
min,max,clear,log/slog. -
Go 1.22: per-iteration loop variables;
ServeMuxpatterns with methods and wildcards;math/rand/v2;for range n. -
Go 1.23: range over functions.
-
Go 1.24:
crypto/rand.Text;omitzerofor JSON;testing.B.Loop. -
Go 1.25:
sync.WaitGroup.Go;testing/synctestgenerally available;net/http.CrossOriginProtection. -
Go 1.26:
new(expr);errors.AsType;go fixrebuilt around modernizers. -
Go 1.27: this page was verified with
go 1.27ingo.mod; the style rules themselves are unchanged. Go 1.28 is not released at the time of writing (expected in February 2027).
References
-
crypto/rand, errors, net/http and sync
-
Google Go Style Guide (community)
-
Uber Go Style Guide (community)
-
Go Proverbs (community)
-
staticcheck and golangci-lint (community)