Go Proverbs Explained

Rob Pike's Go Proverbs in Practice

Rob Pike's Go proverbs are concise best practices for writing idiomatic, efficient, and maintainable Go code.

Code review: The 200-line monster

You open a pull request. The function is named ProcessData. It takes a context, a database connection, a logger, a config struct, and a raw byte slice. It returns a response, an error, a boolean flag, and a map of metrics. The body is 200 lines long. The compiler is happy. The tests pass. The build succeeds.

But you know this code is going to break in three weeks. Someone will add a new metric, forget to update the map, and cause a race condition. Or a new team member will try to reuse the logic and get lost in the nested conditionals. The code works, but it fights the language.

Rob Pike's Go proverbs exist for moments like this. They are not compiler flags. They are not linter rules you can toggle on or off. They are the distilled wisdom of the Go community, capturing the design philosophy that makes Go code readable, maintainable, and robust. When the compiler stays silent, the proverbs speak up. They help you choose between two valid approaches by pointing toward the one that ages well.

Proverbs are heuristics, not laws

Proverbs are mental models. They guide you when you face ambiguity. You can write code that violates every single proverb and still get a binary. The compiler does not care if your code is clever or clear. It only cares if the types match and the control flow is valid.

The value of proverbs comes from shared understanding. When you follow them, other Go developers can read your code with minimal friction. You don't have to explain why you structured a function a certain way. The structure itself signals intent. Breaking a proverb is sometimes necessary, but you should do it with a reason, not by accident.

gofmt handles formatting. Proverbs handle design. You don't argue about indentation; you let the tool decide. You do argue about structure, and the proverbs give you a common vocabulary for that argument.

Clear is better than clever

Clever code saves bytes. It costs cognitive load. Go favors the reader over the writer. The person who reads your code is often you, six months from now, trying to fix a production bug at 3 AM.

// Max returns the larger of a and b.
// Clear is better than clever.
func Max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

A clever version might use a bit hack or a ternary operator disguised as an array index. It might look impressive in a code golf contest. It will confuse everyone else. Go has no ternary operator for this reason. The language encourages explicit control flow.

If you find yourself writing a one-liner that requires a comment to explain, rewrite it. The comment is a tax on the reader. The code should explain itself. Use named returns only when they add clarity, not to save a keyword. Name your variables descriptively. user is better than u. timeout is better than t.

Write code for the person who has to debug it at 3 AM. That person is usually you.

Concurrency is not parallelism

Concurrency is about structure. Parallelism is about execution. Go gives you concurrency primitives. The runtime decides how to map them to CPU cores.

Think of concurrency as handling multiple tasks at once. You can handle multiple phone calls by switching between them quickly. You don't need multiple operators. That's concurrency. Parallelism is having multiple operators answering calls simultaneously.

// Server handles requests concurrently.
// Concurrency is structure, not just speed.
func Server(requests <-chan Request) {
    for req := range requests {
        go Handle(req)
    }
}

The Server function creates a goroutine for each request. This is concurrent structure. The requests are processed independently. The Go runtime scheduler decides whether to run them on one core or many. If you have one core, they still run concurrently, interleaving execution. If you have four cores, they might run in parallel.

Design your system for concurrency first. Define the independent units of work. Define how they coordinate. Then worry about parallelism. If you design for parallelism, you often end up with shared state and locks, which makes the code harder to reason about.

Design for concurrency. Let the runtime handle parallelism.

Share memory by communicating

This proverb is often misunderstood. It does not mean "never use a mutex." It means "prefer channels to coordinate goroutines." Channels express flow. Mutexes protect state. When you use channels, the data moves between goroutines. Ownership is clear. When you use mutexes, data stays in place and goroutines reach in to modify it. Ownership is implicit.

// Worker processes jobs from a channel.
// Share memory by communicating.
func Worker(id int, jobs <-chan Job, results chan<- Result) {
    for job := range jobs {
        // Process job
        result := process(job)
        results <- result
    }
}

The Worker function receives jobs and sends results. It never touches shared variables. The channels define the lifecycle. When the sender closes the jobs channel, the worker exits. The flow is explicit.

Use mutexes when you need high-performance access to a data structure from many goroutines, and the overhead of channel sends would be too high. Use channels when you are coordinating tasks, passing ownership, or building pipelines.

Channels express flow. Mutexes protect state. Pick the one that matches your mental model.

Make the zero value useful

In Go, var x T creates a variable of type T with the zero value. If the zero value is useful, you don't need a constructor. Constructors add ceremony. They force the caller to remember to call NewT() instead of just using T{}.

// Buffer holds bytes.
// The zero value is ready to use.
type Buffer struct {
    data []byte
}

// Write appends p to the buffer.
func (b *Buffer) Write(p []byte) (int, error) {
    b.data = append(b.data, p...)
    return len(p), nil
}

You can declare var buf Buffer and call buf.Write() immediately. The slice data starts as nil, which is a valid empty slice. append handles it correctly. No initialization needed.

Compare this to a type that requires a constructor:

// BadBuffer requires initialization.
// The zero value is not useful.
type BadBuffer struct {
    data []byte
}

func NewBadBuffer() *BadBuffer {
    return &BadBuffer{
        data: make([]byte, 0, 64), // Pre-allocate
    }
}

If you use var buf BadBuffer, the data field is nil. If Write assumes capacity, it might panic or perform poorly. The user must remember NewBadBuffer. This is a trap.

If var x T doesn't work, reconsider the type. Move initialization into methods or use a struct that works from zero.

Accept interfaces, return structs

This proverb guides API design. Functions should accept interfaces and return concrete types. This keeps the interface close to the consumer and allows the implementation to change without breaking callers.

// Logger defines the interface for logging.
// Accept interfaces.
type Logger interface {
    Log(msg string)
}

// WriteLog writes a message using the logger.
func WriteLog(l Logger, msg string) {
    l.Log(msg)
}

// NewFileLogger returns a concrete logger.
// Return structs.
func NewFileLogger(path string) Logger {
    return &fileLogger{path: path}
}

WriteLog accepts Logger. Any type that implements Log can be passed. This makes testing easy. You can pass a mock logger without changing the function signature. NewFileLogger returns Logger, but the implementation is *fileLogger. The caller doesn't need to know about the struct.

If you return an interface, you tie the caller to that interface. If you later want to return extra methods, you have to change the interface and break all callers. Returning a struct gives you the freedom to add methods later.

Interfaces belong to the function that uses them, not the package that implements them.

Errors are values

In Go, errors are not exceptions. They are values returned from functions. The error type is an interface. You handle errors explicitly. You check them. You wrap them. You return them.

// OpenConfig reads the configuration file.
// Errors are values. Handle them.
func OpenConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("failed to read config %s: %w", path, err)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("failed to parse config: %w", err)
    }
    return &cfg, nil
}

The if err != nil pattern is verbose by design. It forces you to look at the failure path. You cannot accidentally ignore an error. If you try to ignore it, the compiler rejects the program with declared and not used.

Wrap errors with %w to preserve the chain. Callers can use errors.Is or errors.As to check for specific error types. This gives you flexibility without sacrificing clarity.

Errors are data. Treat them like any other return value.

Pitfalls and when to break the rules

Proverbs are guidelines. Taking them too literally leads to bad code.

"Don't communicate by sharing memory" does not mean you should never use a mutex. If you have a high-performance counter that increments millions of times per second, a channel send for every increment adds overhead. A mutex or atomic operation is the right choice. The proverb is about design preference, not performance dogma.

"Reflection is never clear" does not mean reflection is banned. It means if you need reflection, you might be fighting the type system. Use reflection for serialization, testing, or framework code where you cannot know the types ahead of time. Don't use reflection to replace a simple interface or type switch.

"Clear is better than clever" does not mean you should write inefficient code. If a clever algorithm reduces complexity from O(n^2) to O(n log n), use it. Just explain it. The goal is clarity, not stupidity.

The compiler helps you avoid some mistakes. If you pass the wrong type to a function, you get cannot use T as I in argument. If you forget to return a value, you get not enough return values. If you import a package and don't use it, you get imported and not used. These errors are immediate feedback. Proverbs provide feedback on design, which the compiler cannot see.

The worst bug is the one that compiles but violates the mental model of the team.

Decision: Which proverb guides your choice

Use "Clear is better than clever" when optimizing for readability over micro-performance. Use "Concurrency is not parallelism" when designing system structure before worrying about CPU cores. Use "Share memory by communicating" when coordinating goroutines and passing ownership. Use "Make the zero value useful" when defining types that users will instantiate frequently. Use "Accept interfaces, return structs" when building APIs that need to be testable and extensible. Use "Errors are values" when handling failure modes that are expected and recoverable. Use plain sequential code when concurrency adds complexity without benefit.

Where to go next