Idiomatic Go: the design philosophy as a working tool
Fifteen lessons taught you what Go can express. This one asks the question that actually decides whether code survives a team: of the many ways the language permits, which one should you write? "Idiomatic" is the name for that answer — and in Go it is not folklore or taste-policing. It is the bargain from Lesson 00 applied at the granularity of a function signature: choose the form that costs the next reader the least, and let the tools and the community remove the argument about everything else. By the end you can read a code review comment that just says "not idiomatic" and know exactly what cost it is pointing at.
gofmt, go vet, go test, the race detector) that mechanically enforces a baseline, and Lessons 05–07 and 12–13 gave you interfaces, errors, and packages, which is where most idiom decisions actually live.New capability: defend or critique a Go API design out loud in the language the community uses — "accept interfaces, return structs," "a little copying," "the zero value should be useful" — and explain the reader-cost each proverb is protecting against, rather than reciting it.
1 · What "idiomatic" actually means
The language permits many spellings of the same intent. You can return (T, error) or stash the error on a struct field; you can take an interface parameter or a concrete type; you can name a variable i or theCurrentLoopIndex. The compiler accepts all of them. "Idiomatic" is the claim that among forms the compiler treats as equal, the team does not: one of them costs the next reader less, and that one is the idiom. It is the bargain's half-one — complexity must be earned — pushed down to the smallest scale, where the unit of complexity is a reader's moment of hesitation.
This is why "not idiomatic" is a real review comment and not snobbery. It means: you wrote a form that works but makes the reader stop and reconstruct intent the standard form would have made obvious. The cost is invisible to you, the writer, who already knows what you meant — exactly the asymmetry Lesson 00 named. Idiom is the team's shared compression: a vocabulary of expected shapes, so a reader spends attention on what is unusual about your code instead of decoding what should have been routine.
Go enforces this in two layers, and the split is itself a design decision:
| Layer | Enforced by | What it removes |
|---|---|---|
| Mechanical — formatting, obvious bugs | gofmt, go vet, goimports | The argument. Brace style, indentation, import order, shadowed errors — there is one output, so no one debates it in review (Lesson 15). |
| Social — naming, API shape, what to leave out | code review, Effective Go, the proverbs, reading the stdlib | The drift. Judgement calls a formatter can't make, kept convergent by a shared, written-down culture rather than re-litigated per team. |
The mechanical layer is the cheaper and more famous one: gofmt has exactly one output for any valid program, so the entire global "tabs vs spaces" debate costs Go zero engineer-minutes. But the mechanical layer is deliberately shallow — it never touches naming or design. Those are the social layer, and the proverbs are how the community keeps the social layer from fragmenting into a thousand local dialects, which is the precise failure mode (two teams, two languages) that motivated Go in the first place (Lesson 01).
2 · The proverbs that carry weight
Rob Pike's "Go Proverbs" are one-liners, and like all aphorisms most of their value is in the reasoning they compress. Here are the ones that change how you write code, each stated as the cost it removes — answering question 1 (what can I leave out?) and question 4 (behavior or concretion?) from Lesson 00.
io.Reader) can be satisfied by almost anything, so it composes everywhere (Lesson 13). A ten-method interface can be satisfied by almost nothing and welds callers to one shape. Small interfaces are more abstract, not less — a counterintuitive truth most languages get backwards.%w, switch on them with errors.As, because they are just values like any other.var b bytes.Buffer and var mu sync.Mutex are ready to use at zero. Design so the empty value is the valid value.Notice none of these is about syntax. They are all about where you put complexity — in a smaller interface, a local copy, a visible error, a useful zero. That is the through-line: idiom is the bargain applied to design decisions too small for the language spec to legislate.
3 · Idiom as concrete API-design rules
The proverbs become real when you write a signature. Here are the decisions where idiom does the most work, with the idiom and the anti-pattern side by side.
Naming carries scope information. Go's convention is that name length tracks the distance between declaration and use: a loop index is i, a receiver is one or two letters, a package-level exported function gets a full descriptive name. And because export is just capitalization (Lesson 12), the name is the access modifier — Parse is public API, parse is internal. There is also no stutter: inside package http the type is Server, used as http.Server, never http.HTTPServer.
// anti-pattern: package-qualified name stutters, and the receiver over-names
func (theBuffer *Buffer) BufferReset() { ... } // read as buf.BufferReset()
// idiom: the package already says "buffer"; the receiver is terse
func (b *Buffer) Reset() { ... } // read as buf.Reset()
Accept interfaces, return structs. This is the most consequential API rule in the language. Take the narrowest behavior you actually use as the parameter, so the widest set of caller types fits; return the concrete type, so callers keep full access and you can grow the type later.
// idiom: accepts anything that can be read; returns a concrete *os.File
func ReadConfig(r io.Reader) (*Config, error) { ... }
// the caller can pass a file, a network conn, a bytes.Buffer, a test fake —
// because io.Reader is one method (Lesson 13). If ReadConfig had demanded
// *os.File, none of those would fit.
// anti-pattern: returning an interface hides the concrete type's other
// methods and freezes the API at its narrowest surface
func OpenStore() Store { ... } // caller can never reach a future *Store.Stats()
Define the interface where it is consumed, not where the type is defined. A C++/Java instinct is to ship an interface next to the implementation. Idiomatic Go puts the interface in the consuming package and keeps it as small as that consumer needs — because interface satisfaction is structural (Lesson 05), the implementer never imports it. This keeps the dependency arrow pointing the right way and the interface honestly minimal.
// package report (the consumer) declares exactly the behavior IT needs:
type Fetcher interface {
Fetch(ctx context.Context, id string) ([]byte, error)
}
// package storage (the implementer) just has a method with that signature.
// It never imports report. The two are coupled only by a shape, not a name.
Errors: wrap with context, expose with sentinels or types. An error should accumulate context as it travels up (so the message says where), while staying programmatically inspectable (so a caller can branch on what). Wrap with %w; let callers test with errors.Is (sentinel) or errors.As (typed) — Lesson 07.
// idiom: add context with %w, preserving the chain for errors.Is/As
if err != nil {
return fmt.Errorf("load config %q: %w", path, err)
}
// caller: errors.Is(err, os.ErrNotExist) still works through the wrap.
// anti-pattern: %v flattens the error to a string and breaks Is/As
return fmt.Errorf("load config: %v", err) // chain is lost
Make the zero value do something. If var x T is immediately usable, the constructor becomes optional and a class of nil-deref and uninitialized-state bugs disappears. The stdlib lives this: sync.Mutex, bytes.Buffer, and time.Time all work at zero.
// idiom: zero value is ready; no New() required
type Set struct{ m map[string]struct{} }
func (s *Set) Add(k string) {
if s.m == nil { s.m = map[string]struct{}{} } // lazy init on first write
s.m[k] = struct{}{}
}
var s Set; s.Add("x") // works — no constructor, no panic
4 · The line between idiom and dogma
Here is the trap. A proverb is compressed reasoning; cargo-culting it without the reasoning is itself unearned complexity — the exact thing the bargain forbids. Idiom is a default, not a law, and the senior skill is knowing when the cost the proverb protects against isn't present.
- A function reads from a source and you don't care what kind → take
io.Reader. The abstraction is free and the caller set is huge. - You'd pull a 40-line dependency for one helper → copy the helper, cite the source in a comment. The graph edge costs more than the lines.
- Two workers hand items off in sequence → a channel. Ownership movement is visible (Lesson 09).
- Defining a one-method interface for a type with exactly one implementation and no test fake — abstraction no caller benefits from. Just use the struct.
- Copying 400 lines to avoid a battle-tested dependency — "a little copying" was about a little; now you own a fork.
- Forcing a channel pipeline onto shared-counter state a
sync.Mutexmodels in three lines (Lesson 11). Channels orchestrate; mutexes protect.
The discipline that keeps you on the right side of the line is not memorizing the proverbs — it is reading the standard library, which is the idiom made executable. When you're unsure how to shape an API, find the closest thing in io, net/http, or encoding/json and copy its taste (Lesson 13). The stdlib is the community's accumulated answer to "which form costs the reader least," and it is one go doc away.
Checkpoint exercise
var x T usable, or does it panic until a constructor runs? Make the empty value valid if you can. For each change, write one sentence naming the reader-cost you removed — and for at least one rule, find a case where applying it would be dogma (no caller benefits) and say why you'd leave the code alone. That last step is the lesson.Where this points next
You now have every piece — values and pointers, methods and interfaces, composition, errors, goroutines and channels and context, packages, the stdlib's taste, generics, tooling, and the idiom that chooses among them. Lesson 17, the capstone, spends all of it at once: we design one small concurrent service end to end — interfaces accepted and structs returned, errors wrapped up a call stack, goroutines coordinated by channels and stopped by a context, the whole thing packaged and tested — and walk the five questions over the finished design. Then it closes the series the way the C++ track does: an honest accounting of what the bargain cost us, so you leave able to defend Go's choices and name their price in the same breath.
gofmt, vet) that removes the argument about formatting by having exactly one output, and a social one (proverbs, review, the standard library) that keeps the unlegislatable judgement calls — naming, API shape, what to leave out — from fragmenting into local dialects. The proverbs that carry real weight are all about where you put complexity, not syntax: clear over clever; the bigger the interface the weaker the abstraction; accept interfaces, return structs; a little copying beats a little dependency; make the zero value useful. The transferable habit is to read each proverb as compressed reasoning about reader-cost and to apply it as a default rather than a law — because cargo-culting an idiom you can't justify is itself the unearned complexity the bargain exists to refuse.Interview prompts
- What does "idiomatic Go" actually mean, mechanically? (§1 — among forms the compiler treats as equivalent, the idiom is the one that costs the next reader the least to understand; it's the "complexity must be earned" bargain applied at function-signature granularity, not a style preference.)
- Why does Go split enforcement into mechanical and social layers? (§1 —
gofmt/vethave one output so they remove the argument about formatting at zero cost; naming and API shape can't be mechanized, so the proverbs and stdlib keep that social layer convergent instead of re-litigated per team.) - Explain "the bigger the interface, the weaker the abstraction." (§2 — a one-method interface like
io.Readeris satisfiable by almost any type so it composes everywhere; a large interface is satisfiable by almost nothing and couples callers to one shape — small means more abstract, not less.) - Why "accept interfaces, return structs," and what breaks if you return an interface? (§2, §3 — accept the narrowest behavior so the widest caller set fits; return the concrete type so callers keep full access and you can add methods later. Returning an interface hides capability and freezes the API at its narrowest surface.)
- Where should an interface be defined, and why? (§3 — in the consuming package, sized to what that consumer needs; because satisfaction is structural the implementer never imports it, keeping dependency arrows correct and the interface honestly minimal.)
- What's the difference between idiom and dogma — give an example of each. (§4 — idiom is a default justified by a real reader-cost (take
io.Readerwhen you don't care about the source); dogma is the slogan without the cost (defining a one-method interface for a single implementation no caller abstracts over).) - Why is "make the zero value useful" worth designing for? (§2, §3 — the zero value is reachable for free (Lesson 02); if it's also usable, the constructor becomes optional and uninitialized-state/nil-deref bugs vanish — as with
sync.Mutexandbytes.Buffer.)