Idioms & Common Gotchas
You can write working Go without knowing any of this. But the gap between "compiles and runs" and "looks like Go" is a handful of conventions - plus a short list of gotchas, mostly about how Go quietly shares memory, that bite essentially every developer once.
The idioms: Go prefers small, composable pieces over big inheritance hierarchies - small interfaces, structs glued together by embedding, functions that take a narrow interface and hand back a concrete thing. The gotchas: Go does what it says, not what you assumed, and the assumptions that trip people up are almost always about slices, loops, and nil.
Interfaces: small, and satisfied implicitly
An interface is a list of method signatures - a description of behavior, not data. Go's twist: a type satisfies an interface automatically, just by having the right methods. No implements keyword. If it has the methods, it fits.
Why this changes how you design. Because satisfaction is implicit, interfaces in Go are usually tiny - often a single method - and frequently defined by the consumer, not the producer. The most famous is io.Writer:
type Writer interface
Anything with a Write([]byte) (int, error) method is an io.Writer - a file, a network connection, an in-memory buffer, an HTTP response - none of them ever mentioning the interface.
package main
import (
"fmt"
"os"
"strings"
)
func greet(w fmt.Stringer)
type user struct
func () string
func main()
$ go run main.go
user: Ada
to the terminal
to a buffer
user became a fmt.Stringer purely by having a String() method - we never declared the relationship. fmt.Fprintln wrote to two completely different destinations (the terminal and an in-memory strings.Builder) because both satisfy io.Writer. Small interfaces plus implicit satisfaction is why Go code composes so freely: you write to "anything that can be written to," and the caller picks what that is.
Struct embedding: composition over inheritance
Go has no class inheritance. Instead, you embed one struct inside another by declaring it without a field name, and the outer struct gets the inner one's fields and methods promoted as its own - composition that reads like inheritance, without the hierarchy.
package main
import "fmt"
type Engine struct
func ()
type Car struct
func main()
$ go run main.go
vroom
200
Car embedded Engine, so c.Start() and c.Horsepower worked directly - the Engine's method and field were promoted onto Car. There's no inheritance here: Car has an Engine and borrows its surface, composing pieces rather than building a tower of base classes.
"Accept interfaces, return structs"
A widely-followed guideline: make function parameters interfaces (callers can pass anything that fits), but make return values concrete structs (callers get the real thing with all its methods).
Why it works. Accepting an interface keeps your function flexible - func Save(w io.Writer, ...) accepts a file, a buffer, a socket, anything writable. Returning a concrete struct keeps your output useful - the caller gets the full type and you're free to add methods later without breaking an interface contract. "Be liberal in what you accept, conservative in what you return," applied to types.
💡 Key point. When in doubt: parameters should be as small and general as the function needs (often a one-method interface); returns should be the concrete struct. Don't define an interface "just in case" - define it where it's consumed.
defer: cleanup that can't be forgotten
defer schedules a function call to run when the surrounding function returns - no matter how (normal path, early return, or a panic) - guaranteeing cleanup sits right next to the thing that needs cleaning up.
Why it's the idiom for cleanup. You open something, then immediately defer closing it. The two lines live together, so you can't open-without-closing, or forget the close at the bottom of a long function with five early returns. Deferred calls run last-in, first-out, right for nested resources (close the inner thing before the outer).
package main
import "fmt"
func main()
$ go run main.go
1. start
2. work
3. cleanup runs last
The defer was written first but ran last - Go held that call until main was about to return. This is why defer f.Close() right after os.Open (Phase 7) is bulletproof: the close fires on the way out, even with a return or panic in between. Pair every "open / lock / acquire" with an immediate defer of its "close / unlock / release."
The gotcha cheat-card
Skim these now so they're familiar when they happen; the notes below explain the two sharpest.
| Gotcha | What bites you | The fix |
|---|---|---|
| A nil interface is not nil | Returning a typed-but-nil pointer as an error makes err != nil true even though "there's no error" |
Return a literal nil for the error, never a nil-valued concrete type held in an interface |
Slice append aliasing |
append may return a slice that shares the original's backing array, so writing to one silently changes the other |
If you need independence, copy into a fresh slice (or append to a nil/fresh slice) |
| Range loop variable capture (pre-1.22) | In Go ≤1.21, goroutines/closures in a for loop all captured the same loop variable, seeing only its final value |
Go 1.22+ fixed this (each iteration gets a fresh variable); on older Go, copy v := v inside the loop |
| Unused imports / variables won't compile | A leftover import or an unused local is a compile error, not a warning | Delete it - or use the blank identifier _ deliberately for an intentionally-unused import |
| Exported = Capitalized | A lowercase name (doThing, count) is package-private; only DoThing, Count are visible to other packages |
Capitalize the first letter to export; this is the visibility rule, there's no public keyword |
| Zero values, not "undefined" | An uninitialized variable isn't null/garbage - it's the type's zero value (0, "", false, nil for pointers/slices/maps) |
Lean on it (var count int is a usable 0); but ⚠️ a nil map can be read but panics if you write to it - make it first |
⚠️ The nil-interface trap, explained. Genuinely confusing the first time - worth slowing down for:
package main
import "fmt"
type myError struct
func () string
func doThing() error
func main()
$ go run main.go
false
An interface value carries two things: a type and a value. doThing returned a *myError that was nil - but the interface holds the type *myError plus a nil value, and an interface is only equal to nil when both parts are empty. So err == nil is false even though the underlying pointer is nil, and the caller's if err != nil wrongly thinks there was an error. Fix: return a bare nil when there's no error - never a nil-valued concrete type stuffed into the interface.
⚠️ Slice append aliasing, explained. A slice is a small header (pointer, length, capacity) over a backing array. append reuses that array when there's spare capacity - so two slices can quietly point at the same memory:
package main
import "fmt"
func main()
$ go run main.go
[1 2 99]
b := a[:2] made b a window onto the same array as a, with room left over. append(b, 99) had spare capacity, so instead of allocating, it wrote 99 into the slot a[2] was using - mutating a as a side effect. When you need a slice you can grow without disturbing the original, copy the data into a fresh slice first.
📝 Terminology. A zero value is the default Go gives every variable you don't initialize - 0 for numbers, "" for strings, false for bools, nil for pointers, slices, and maps. Go has no "uninitialized garbage" and no separate null; the zero value is the starting state, and good Go leans on it (an empty sync.Mutex is ready to use, a nil slice appends fine).
Recap
- Interfaces are small and implicit - satisfied automatically by having the methods; often one method, often defined by the consumer (
io.Writer). - Embedding, not inheritance - compose structs by embedding; fields and methods get promoted.
- Accept interfaces, return structs - flexible inputs, concrete outputs.
deferfor cleanup - schedule the close/unlock right next to the open/lock; it runs on every exit path, LIFO.- The cheat-card - nil interface ≠ nil, slice append aliasing, range-var capture (fixed in 1.22), unused imports/vars are errors, exported = Capitalized, and zero values are real defaults (but don't write to a nil map).
That's idiomatic Go. You can now read other people's Go and write code that looks like it belongs. Next: where Go actually shines, what to build next, and where to go from here.
← Phase 8: The Ecosystem & Tooling · Guide overview · Phase 10: Interfaces in Depth →
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 2 questions
1. Go interfaces are satisfied...
2. A subtle Go gotcha is that a nil interface...