Go Proverbs Explained With Short Working Code Examples
The Go proverbs from Rob Pike's 2015 talk restated in plain words and grouped by theme, with runnable code for the ones you can demonstrate.

Every language community has a way of passing on its taste, and Go's is a list of short sentences. They come from a talk Rob Pike gave at Gopherfest in 2015, and they are collected, with the original slide wording, at go-proverbs.github.io. They are pithy on purpose, which makes them easy to repeat and easy to misapply. A proverb without an example is a slogan, and a proverb with a counterexample is a trade-off you can reason about. This guide states each one in my own words, groups them by theme, and runs code for the ones that can be demonstrated. I did not reproduce the list verbatim, so use the site for the exact text. The guide sits in the Go basics path and links to the articles that treat each idea in depth, and it is a good companion to what idiomatic Go means in practice.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Concurrency: three proverbs
Pass ownership with channels instead of sharing memory under locks. The first proverb is the most quoted and the most misread. It does not say "never use a mutex". It says that when you can design the program so that only one goroutine touches a piece of data at a time, and other goroutines ask it to act by sending messages, you avoid a category of bugs. Here is a counter both ways, each with a thousand goroutines:
package main
import (
"fmt"
"sync"
)
func withMutex() int {
var mu sync.Mutex
var wg sync.WaitGroup
count := 0
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
return count
}
func withOwner() int {
increments := make(chan struct{})
done := make(chan int)
go func() {
count := 0
for range increments {
count++
}
done <- count
}()
var wg sync.WaitGroup
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
increments <- struct{}{}
}()
}
wg.Wait()
close(increments)
return <-done
}
func main() {
fmt.Println(withMutex(), withOwner())
}1000 1000Both return 1000. In the second, only one goroutine ever touches count, so there is nothing to lock, and the data race is impossible by construction. For a counter, the mutex version is shorter and faster, and no one should write the channel one. The proverb pays off for larger state, a connection table or a game world, where a single owner serializes access and the interface to it is a set of messages. See mutexes for when the lock is the better tool.
Channels coordinate, mutexes protect. The second proverb is the practical complement: use a channel to hand work over, signal completion or fan results in, and use a mutex to guard a data structure that several goroutines read and write. A cache is the textbook mutex case, and a worker pipeline is the textbook channel case. Worker pools and pipelines are the channel side.
Concurrency is not parallelism. Concurrency is how you structure a program as independent pieces, and parallelism is running things at the same moment on several CPUs. A concurrent program can run on one core, and a parallel one that is not concurrent is a vectorized loop. Go's scheduler decides how much of your concurrent structure runs in parallel, as the scheduler article describes. The practical corollary is that you should design for the structure first and measure whether parallel speed-up exists second.
Design: interfaces, zero values and dependencies
The bigger the interface, the weaker the abstraction. An interface with one method is satisfied by thousands of types. One with ten is satisfied by one type, the one you wrote it for. The proof is in the standard library:
package main
import (
"bytes"
"fmt"
"io"
"os"
"strings"
)
func main() {
sources := []io.Reader{
strings.NewReader("from a string\n"),
bytes.NewBufferString("from a buffer\n"),
io.LimitReader(strings.NewReader("limited to ten bytes of this\n"), 10),
}
for _, src := range sources {
n, _ := io.Copy(os.Stdout, src)
fmt.Println(" <", n, "bytes>")
}
}from a string
< 14 bytes>
from a buffer
< 14 bytes>
limited to < 10 bytes>io.Copy knows nothing about strings or buffers, because it asks only for something with Read. Three unrelated types plug in, and a fourth, a file or a network connection, would work too. Interfaces in Go builds on this.
Make the zero value useful. Design types so that var x T is ready to use, as sync.Mutex and bytes.Buffer are. It removes constructors, makes embedding easy and lets a struct field of that type work with no initialization. Zero values and useful zero values cover the patterns.
interface{} says nothing. An empty interface carries no information about what it holds, so it moves type errors from compile time to run time. Use it where the code truly handles anything, as fmt does, and give everything else a real type or a type parameter, as the empty interface article shows.
A little copying is better than a little dependency. If you need a five line helper, write it. A dependency is code you must keep updated, audit and build, for a function you could own. Compare importing a module for a trivial set operation with this:
package main
import "fmt"
func contains(xs []string, target string) bool {
for _, x := range xs {
if x == target {
return true
}
}
return false
}
func main() {
fmt.Println(contains([]string{"a", "b"}, "b"), contains(nil, "a"))
}true falseSince Go 1.21 slices.Contains does this in the standard library, which is the best kind of dependency, but the principle is the one that matters. The proverb is about proportion: copy a small function, depend on a large one that you could not write yourself. Do not copy a crypto routine.
Style and tooling
Gofmt's style is nobody's favorite, and gofmt is everyone's favorite. The tool ends formatting debates by being the only option, as the gofmt article explains. It is the most successful proverb: nobody argues about it any more.
Clear is better than clever. A clever line saves a line and costs every future reader a minute. The year test below shows both. The clever version is shorter, and nobody remembers which rule it encodes:
package main
import "fmt"
func leapClever(y int) bool { return y%4 == 0 && (y%100 != 0 || y%400 == 0) }
func leapClear(y int) bool {
if y%400 == 0 {
return true
}
if y%100 == 0 {
return false
}
return y%4 == 0
}
func main() {
for _, y := range []int{1900, 2000, 2023, 2024} {
fmt.Println(y, leapClever(y), leapClear(y))
}
}1900 false false
2000 true true
2023 false false
2024 true trueBoth are correct. The second reads as the rule from the calendar, top-down, and I would rather debug it. This is a mild case. The worse cases involve bit tricks and nested ternary-style expressions, and the answer is the same: spend the extra lines.
The unsafe edges: four proverbs
Four of the proverbs warn about leaving the language's safety. Cgo is not Go, and cgo and syscalls must always be guarded with build tags, because C code brings its own memory model, slower calls and a build that depends on a C compiler, and platform-specific system calls do not exist everywhere. With unsafe, there are no guarantees: the compiler and runtime are free to change layouts, and the code you write against them is yours to maintain. Reflection is never clear, since it trades static types for run-time lookup by string, and the code reads as a puzzle. The practical rule from all four is to confine them. Wrap cgo in one package with build tags and a pure Go fallback, hide unsafe behind a small tested function and keep reflection to the encoders and validators that need it, as struct tags shows.
Errors, documentation and panic
Errors are values. An error is an ordinary value, so you can store it, compare it and write code that accumulates it. Rob Pike's own illustration, in the Go blog's Errors are values, is a writer that remembers the first error and skips later writes, so the caller checks once:
package main
import (
"fmt"
"io"
"strings"
)
type errWriter struct {
w io.Writer
err error
}
func (e *errWriter) Write(p []byte) {
if e.err != nil {
return
}
_, e.err = e.w.Write(p)
}
func main() {
var sb strings.Builder
ew := &errWriter{w: &sb}
ew.Write([]byte("header\n"))
ew.Write([]byte("body\n"))
ew.Write([]byte("footer\n"))
fmt.Print(sb.String())
fmt.Println(ew.err)
}header
body
footer
<nil>Three writes, one check at the end. This removes the if err != nil after every line, and it is a pattern, not a rule: use it when a sequence of operations can safely continue to skip after the first failure. The bufio.Writer and bufio.Scanner types in the standard library do exactly this.
Do not just check errors, handle them. Returning err unchanged is a check. Handling is adding context, retrying, falling back or deciding to stop, as in wrapping errors with context and retries with backoff.
Design the architecture, name the components, document the details. Order matters: the shape of the system first, then the names that carry meaning, and then the comments that record what code cannot say. Documentation is for users: a doc comment explains what to rely on, not how the function is built, as doc comments describes.
Do not panic. In library code, return an error. A panic is for a programmer mistake that cannot be recovered locally, and it should not cross a package boundary as ordinary control flow. Panic or error draws the line.
A closer look at not panicking
The last proverb deserves a demonstration, because "return an error" is easy to say and the alternative is tempting when a function sits deep in a call chain. Here is a parser written both ways, and a caller that survives each:
package main
import (
"errors"
"fmt"
"strconv"
)
func mustParsePanics(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
return n
}
func parse(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse %q: %w", s, err)
}
return n, nil
}
func safe(f func() int) (n int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return f(), nil
}
func main() {
fmt.Println(parse("42"))
fmt.Println(parse("forty-two"))
n, err := safe(func() int { return mustParsePanics("forty-two") })
fmt.Println(n, err)
_, err = parse("x")
var numErr *strconv.NumError
fmt.Println(errors.As(err, &numErr), numErr.Func)
}42 <nil>
0 parse "forty-two": strconv.Atoi: parsing "forty-two": invalid syntax
0 recovered: strconv.Atoi: parsing "forty-two": invalid syntax
true AtoiThe error-returning parse tells the caller what happened and keeps the underlying *strconv.NumError, which errors.As recovers, so the caller can branch on the cause. The panicking version also works, but only because the caller wrapped it in a recover, a safety net that every caller must remember to build. In a library that is a tax on all its users. Reserve panic for a violated invariant, an index you proved cannot be out of range or a Must function that runs at program start, as the Must functions article says. Even then, a goroutine that panics without a recover takes the whole process down, which is the right outcome for a real bug and a surprise for an error that should have been a return value.
When the proverbs pull against each other
The list is not a set of consistent rules. A little copying is better than a little dependency, and yet duplicating a function in five places invites five bugs, so the copying proverb applies to small stable helpers and not to logic you will need to fix. Clear is better than clever, and a hot inner loop sometimes needs the cleverness, in which case the answer is to put it in a small function with a comment and a benchmark, not to spread it. Do not communicate by sharing memory, and sync/atomic and a mutex are the right tool for a counter that is read a million times a second. Make the zero value useful, and a type that wraps an open file cannot be, so it gets a constructor.
When two proverbs disagree, the question underneath both is who will read this next, and what will they have to know. That is a better test than the text of any proverb. I use them in review as prompts, and I do not cite them as authority, since "the proverb says so" is the weakest possible argument for a design and the proverbs themselves are a summary of arguments, not a replacement for them.
The proverbs as a review checklist
The most practical way I know to use the list is as a set of questions to ask of a pull request. Each proverb maps to something you can look for in a diff:
| Proverb in my words | The question to ask | What to look for |
|---|---|---|
| Pass ownership over channels | Who is allowed to touch this data? | A map or slice used from several goroutines with no lock and no owner |
| Channels coordinate, mutexes protect | Is the right tool in use? | A channel acting as a lock, or a mutex held while sending |
| Small interfaces | Does the parameter need all these methods? | An interface with a method nobody calls |
| Useful zero value | Does the constructor do anything the zero value could? | New functions that only set defaults |
any says nothing | Can this have a real type? | map[string]any and any parameters in business logic |
| Little copying, little dependency | What did this import cost? | A module added for one function |
| Clear over clever | Can I explain this in one sentence? | One-line expressions with three operators |
| Guard the unsafe edges | Is this confined? | unsafe, reflect or cgo outside one package |
| Errors are values | Is the error handled or only checked? | return err with no context, _ = f() on a write |
| Do not panic | Does this panic on bad input? | panic in code that parses or validates |
A review that goes through the table catches more than style. It tends to find the ownership questions that become data races, the dependency that nobody discussed and the error that is returned three layers up with no hint of where it came from. It is also fast: each row is a ten-second look at the diff. The first time you run it on your own code, expect to find two or three rows that apply, and fix those, rather than trying to satisfy the whole table at once.
How to use the list
Treat the proverbs as the questions a reviewer asks, not as laws. When a design makes you reach for a big interface, ask what the smallest one is. When you write a constructor, ask whether the zero value could work. When you add a dependency, ask what it costs against copying. The proverbs are cheap to remember and expensive to ignore, which is the reason they are still repeated. They also fit a specific era of Go: generics, log/slog and errors.Join came after, and the spirit applies to them, even if the list does not mention them. The original talk and the full list are at go-proverbs.github.io, and the design philosophy behind them is in the Go FAQ.


