- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a pull request. Someone drops a link to
Google's Go Style Guide
in the comments and asks you to "follow the decisions doc." The
review stalls while two engineers argue about whether an empty slice
should be nil or []T{}, and whether the struct you just wrote has
its fields in the wrong order.
The guide is worth reading. It is three documents: the Style Guide
(the short list of principles), Style Decisions (the long list of
specific calls), and Best Practices (patterns). It codifies how Go
gets written inside a company with a very large monorepo and a lot of
reviewers who need to agree.
That last part matters. Some of the rules exist because Google has
thousands of engineers touching shared code. Your team of six does not
have Google's constraints, so you can take the good rules and leave the
ones that only pay off at that scale. Here are four the doc is specific
about, and where each lands.
Adopt: prefer nil slices over empty ones
The guide says to prefer nil slices. A nil slice and an empty
[]T{} slice behave the same for the operations you use every day.
var a []int // nil slice
b := []int{} // empty, non-nil slice
fmt.Println(len(a), len(b)) // 0 0
a = append(a, 1) // works fine on nil
b = append(b, 1) // works fine too
for range a { } // ranges zero times, no panic
len, append, and range all treat a nil slice as a
zero-length slice. So the default var results []Item is a working
empty slice with no extra allocation. You do not need make or []T{}
to "initialize" it before appending.
Adopt this one. It removes a whole class of pointless initialization
and reads cleaner. One caveat the guide flags: encoding/json treats
them differently.
type Resp struct {
Items []string `json:"items"`
}
var nilResp Resp
nilResp.Items = nil
// marshals to: {"items":null}
emptyResp := Resp{Items: []string{}}
// marshals to: {"items":[]}
If a JSON client expects [] and not null, that difference is real
and can break a frontend. That is the one place to reach for []T{}
on purpose. Everywhere else, let it be nil.
Adopt: wrap errors with %w only when the caller needs the cause
The guide's error-wrapping rule is more precise than "always use
%w." The decision is about whether the caller should be able to
inspect the underlying error.
Use %w when you want callers to reach the wrapped error through
errors.Is or errors.As:
func loadConfig(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("loadConfig %q: %w", path, err)
}
// ...
}
Now a caller can write errors.Is(err, os.ErrNotExist) and it works,
because the chain is preserved.
Use %v when you deliberately do not want to expose the cause,
because the underlying error is an implementation detail you might
change later:
func (s *Store) Save(u User) error {
if err := s.db.Insert(u); err != nil {
return fmt.Errorf("save user %d: %v", u.ID, err)
}
return nil
}
Here the SQL driver's error type is not part of your API. Wrapping
with %w would let callers depend on it, and swapping the driver
later would break them. %v flattens it to a string and keeps your
contract narrow.
Adopt this one. The distinction between "I'm exposing this cause on
purpose" and "this cause is internal" is a design decision, and the
guide is right to make you choose per call site instead of reaching
for %w on autopilot.
Adopt: name tests and failures so you can read the output
The guide is opinionated about test output, and it is correct. A test
failure should tell you what was tested, what you got, and what you
wanted, without opening the source file.
Table tests get named cases, and the subtest name comes from the case:
func TestParse(t *testing.T) {
tests := []struct {
name string
in string
want int
wantErr bool
}{
{name: "single digit", in: "7", want: 7},
{name: "empty input", in: "", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("Parse(%q) err = %v, wantErr %v",
tt.in, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("Parse(%q) = %d, want %d",
tt.in, got, tt.want)
}
})
}
}
Two things the guide pushes here. Name the case field name and pass
it to t.Run, so a failure prints TestParse/empty_input and you know
which row broke. And write failure messages as
FuncName(args) = got, want X, so the message reads like the assertion
it failed. When this test breaks in CI, the log line is enough to
locate the bug.
Adopt all of it. It costs nothing at write time and saves you the
round trip of reopening the test to understand a red build.
Skip (mostly): reordering struct fields for alignment
Here is the rule that gets misapplied. There is a vet analyzer,
fieldalignment, that reorders struct fields to shrink memory padding.
Someone reads about it, runs it across the codebase, and now your
structs are sorted by byte size instead of by meaning.
// Grouped by what the fields mean:
type Server struct {
Host string
Port int
ReadTimeout time.Duration
WriteTimeout time.Duration
TLS bool
Verbose bool
}
Now the "optimally" packed version, with the same fields
sorted by byte size instead of by meaning:
type ServerPacked struct {
ReadTimeout time.Duration
WriteTimeout time.Duration
Host string
Port int
TLS bool
Verbose bool
}
The second version might save a few bytes per struct. For a type you
allocate a handful of times, that saving is nothing, and you paid for
it by breaking the logical grouping that helps a reader.
The guide's own principle is that clarity beats micro-optimization.
Field order should follow meaning. Reordering for padding is worth it
only when you allocate the struct in enormous quantities and have
measured that the padding matters. That is a real case for some code,
and a distraction for most of it.
So skip the blanket fieldalignment pass. Keep it in your back pocket
for the rare hot struct where a profiler pointed you at it. Do not let
a linter sort your fields by default.
How to bring this to a team
The guide is a menu, not a mandate. Pull the rules that hold at any
scale into your review checklist: nil slices by default, %w versus
%v as a conscious choice, test names and got/want messages. Leave
the scale-specific ones, like reflexive field reordering, out of the
default flow.
Write the short version down in your repo. Three or four lines that
say "we prefer nil slices, we wrap with %w only to expose a cause, our
test failures print got/want." That is a house style the whole team can
hold in their head, which is the point of a style guide in the first
place.
If this was useful
Style rules like these are downstream of understanding how Go actually
behaves. Nil slices make sense once you know the slice header, and the
%w decision makes sense once you know how the error chain is walked.
The Complete Guide to Go Programming covers the language and runtime
underneath these rules. Hexagonal Architecture in Go shows how to
keep error wrapping honest across the boundary between your domain and
the framework. That boundary is where the %w versus %v call
actually earns its keep.

Top comments (0)