The Go Interface nil Trap — Why err != nil Is True
Contents10 sections
I knew about the bug where you return a nil pointer and err != nil comes out true. What I couldn’t do was explain why it isn’t nil. Tracing it, the cause turned out to be that an interface value has two slots, a type and the data. When you put a nil pointer into an error, the data slot is empty but the type slot gets filled.
The fix is to return a nil of type error directly on the success path. This post first reproduces the bug in code, then walks through the two-slot layout, method sets, and where interfaces should be defined. If you only want the cause, the first section is enough.
You returned a nil pointer, so why is err != nil?
An interface value has two slots: a type slot and a value slot. The next section covers the layout in detail; for now, just remember there are two. The comparison err == nil checks whether both slots are empty. If the value slot is nil but the type slot is filled, the interface isn’t nil.
type MyErr struct{ Code int }
func (e *MyErr) Error() string { return "my error" }
func validate(v int) *MyErr { if v > 0 { return nil // a genuine nil pointer here } return &MyErr{Code: 400}}
func handle(v int) error { return validate(v) // *MyErr(nil) goes into the error interface, filling the type slot with *MyErr}
func main() { err := handle(10) fmt.Println(err == nil) fmt.Println(err)}falsemy errorThere’s no error, yet it’s treated as one. Because Error() returns a string without reading any field of its receiver, it can be called on a nil pointer, so the printed message gave no hint that the interface wasn’t nil.
The compiler doesn’t stop this because the assignment is perfectly legal. *MyErr satisfies error, and a nil pointer is a valid value of that type. The problem lies in the semantics, that putting a nil pointer into an interface makes it non-nil, not in any type error.
| Defense | What it means |
|---|---|
Make the helper return error |
Never return a concrete error pointer in the first place. The most fundamental fix |
Return a literal nil on success |
if e != nil { return e }; return nil. Don’t return the variable as is |
| Static analysis | staticcheck’s SA4023 catches nil comparisons that can never be true |
| Check struct fields too | Storing a nil pointer in struct{ err error } blows up the same way later |
What an interface value really is: two words
From here on it’s about the runtime. An interface variable isn’t a box holding the concrete value. It’s a struct of two pointers, 16 bytes on a 64-bit machine.
Written out conceptually, it looks like this. The real definitions live in internal/abi and shift a little between runtime versions, but the two-slot shape stays.
// Interfaces with methods (io.Reader, error, ...)type iface struct { tab *itab // type slot: concrete type + method table data unsafe.Pointer // value slot}
// Interfaces without methods (any, interface{})type eface struct { _type *_type // no method table needed, just type info data unsafe.Pointer}
type itab struct { Inter *InterfaceType // which interface Type *Type // which concrete type Hash uint32 // hash used by type switches Fun [1]uintptr // variable length: array of method pointers}itabs are usually built at compile time. Only when the type can’t be determined statically, as in a type assertion to an interface, does the runtime build one and cache it in itabTable.
One thing worth pointing out now: Fun is a table fixed to one concrete type. In Java, an object carries its class’s vtable around. In Go, the interface value carries the itab. So the same struct gets a different itab when stored as an io.Reader than when stored as an io.Writer.
🧪 Try it
Seeing the two words with your own eyes
To check whether the type slot really gets filled, I looked inside the interface with unsafe, reinterpreting the interface value as a two-word struct of the same size.
type iface struct{ tab, data unsafe.Pointer }
func dump(label string, err error) { i := (*iface)(unsafe.Pointer(&err)) fmt.Printf("%-14s tab=%p data=%p == nil? %v\n", label, i.tab, i.data, err == nil)}
func main() { var nilIface error var p *MyErr dump("① true nil", nilIface) dump("② nil pointer", p) dump("③ real value", &MyErr{})}① true nil tab=0x0 data=0x0 == nil? true② nil pointer tab=0x4a2e88 data=0x0 == nil? false③ real value tab=0x4a2e88 data=0xc00001… == nil? falseThe tab in ② and ③ is exactly the same. There’s only one itab per (interface, concrete type) pair, and it has nothing to do with whether the stored value is nil. That one line answers why the compiler can’t catch this.
Method sets: the call works, the interface doesn’t
This was the confusion I ran into most in Go. d.Inc() works fine, but var i Incer = d won’t compile. Same method.
Start with what a method really is. A Go method isn’t anything special; it’s a function whose first argument moved to the front. func (c Counter) Show() is essentially func Show(c Counter). So, like every other value passed in Go, the receiver is copied. A value receiver gets a copy, and a pointer receiver gets an address.
type Counter struct{ n int }
func (c Counter) Show() int { return c.n } // value: gets a copyfunc (c Counter) BadInc() { c.n++ } // bumps only the copy, original unchangedfunc (c *Counter) Inc() { c.n++ } // pointer: modifies the original
d := Counter{}d.BadInc(); fmt.Println(d.n) // 0: only the copy changedd.Inc(); fmt.Println(d.n) // 1: rewritten to (&d).Inc()
// method sets: the call works, the interface doesn'ttype Incer interface{ Inc() }var a Incer = &Counter{} // OK: Inc is in the method set of *Tvar b Incer = Counter{} // error: Inc isn't in the method set of TThe spec’s rules fit in three lines.
- The method set of
Tis the methods with receiverT. - The method set of
*Tis the methods with receiverTor*T, both. - Embedding promotes methods, but the receiver kind comes along. If
OuterembedsInnerandInner’s methods have pointer receivers, only*Outersatisfies the interface.
Why the asymmetry: values without an address
This took me the longest while writing. If the compiler rewrites d.Inc() to (&d).Inc() for you, why not do the same for interface assignment? It’s a natural question.
The answer is in the asymmetry itself. Getting a value from a pointer with *p always works, but getting an address from a value with &v doesn’t. The Go spec explicitly limits which values are addressable.
✓Addressable
- Variables
- Pointer dereferences
*p - Slice indexing
s[i] - Fields of an addressable struct, elements of an addressable array
✕Not addressable
- Map elements
m[k] - Function results
f() - String indexing
s[i] - Constants, values stored in an interface
Map elements are the classic case, and the reason is the data structure. As a map grows, it moves entries between buckets wholesale. An address that doesn’t stay valid isn’t a pointer, it’s a time bomb.
The diagram is conceptual. Since Go 1.24, maps use a group-based layout (Swiss tables), but entries can still move.
m := map[string]Counter{"a": {}}
m["a"].Inc()// compile error: cannot call pointer method Inc on Counter
_ = &m["a"]// compile error: invalid operation: cannot take address of m["a"]
// fix ①: take it out, change it, put it backtmp := m["a"]tmp.Inc()m["a"] = tmp
// fix ②: store pointers from the start (for large or frequently changed elements)mp := map[string]*Counter{"a": {}}mp["a"].Inc() // OK: what the map holds is a pointer valueValue or pointer receiver: how to choose
Once the theory is clear, the choice is simple. Instead of memorizing rules, ask one question: would a copy be a problem?
| Situation | Choice and why |
|---|---|
| You modify fields | Pointer. A value receiver only changes the copy |
| The struct is large | Pointer. Every call copies the whole thing |
It contains sync.Mutex or sync.WaitGroup |
Always pointer. A copied lock doesn’t synchronize anything |
| A small, immutable value type | Value. Only where copying is natural, like times or coordinates |
| Within one type | Be consistent. Mixing splits the method sets and muddles which interfaces it satisfies |
The third row hurts most in practice. Take a sync.Mutex through a value receiver and the lock itself is copied, so each call locks a different lock. The code runs, the tests pass, and nothing is actually synchronized: the most miserable kind of bug to find. Fortunately, tooling catches this one.
type Safe struct { mu sync.Mutex n int}
func (s Safe) Bad() { s.mu.Lock(); defer s.mu.Unlock(); s.n++ } // copies of both the lock and nfunc (s *Safe) Good() { s.mu.Lock(); defer s.mu.Unlock(); s.n++ } // locks the original$ go vet ./..../main.go:7:7: Bad passes lock by value: main.Safe contains sync.MutexOne more in the same vein. If several goroutines call a value-receiver method that modifies fields, you won’t see a race. Each goroutine touches its own copy, so even -race stays quiet. The problem is that the original never gets updated. Instead of a race you get a silent no-op, which is found even later.
Who defines the contract: the dependency flips
In Java, to use an interface the implementation has to declare implements Saver. Declaring it means importing that interface, which means the implementation has to know about the contract. This is called nominal typing: types are the same only if their names are.
Go is the opposite. If the method signatures match, the interface is satisfied automatically, with no declaration. That’s structural typing: types are the same if their shapes are. The consequences go well beyond syntactic convenience.
One missing arrow is the whole story of this diagram. In practice, it shows up like this.
It applies retroactively
A type in someone else’s already-published package satisfies the interface you just defined. No need to change the original.
Interfaces get smaller
The user defines it, so it only lists what it needs. That’s why one-method interfaces are the norm.
Mocking is nearly free
A three-line struct in the test file is all it takes. No mocking library, no changes to the implementation.
Implementation packages stay light
With no imports just for interfaces, low-level packages don’t drag in higher-level abstractions.
// consumer package: all it needs is to savetype Saver interface{ Save(v string) error }
func Run(s Saver) error { return s.Save("data") }
// provider package: doesn't know Saver, doesn't import ittype FileStore struct{ dir string }
func (f *FileStore) Save(v string) error { /* ... */ return nil }func (f *FileStore) Open(p string) error { /* unrelated to Saver */ return nil }
// test: a three-line mocktype mockStore struct{ got string }
func (m *mockStore) Save(v string) error { m.got = v; return nil }So interfaces get small
The Go Proverbs include “The bigger the interface, the weaker the abstraction.” At first I took it as a matter of taste, but after seeing the dependency structure above, it’s a logical consequence. Once the consumer defines the interface, the question “why include methods I don’t use?” asks itself.
| Large interface (12 methods) | Small interface (1–2) | |
|---|---|---|
| Cost to implement | All 12 to satisfy it | Only what’s needed |
| Test mocks | Stubs for methods you don’t use | Three lines |
| Blast radius of a change | Add one method and every implementation and mock breaks | Only that consumer |
| What the signature says | “I need this giant thing” | “I only read” |
| Strength of the abstraction | Weak, nearly the concrete type | Strong |
The standard library shows the conclusion directly. io.Reader, io.Writer, and io.Closer each have one method. When you need a combination, you don’t build a big new interface. You compose with interface embedding.
type Reader interface{ Read(p []byte) (n int, err error) }type Writer interface{ Write(p []byte) (n int, err error) }type ReadWriter interface{ Reader; Writer } // composed by embedding✕One service interface for everything
type VMService interface { Create(...) error Delete(...) error List(...) ([]VM, error) // ... 12 in all}
// uses only List, depends on all 12func report(s VMService) { s.List() }✓Split per consumer
type VMLister interface { List(ctx context.Context) ([]VM, error)}
// the signature states its dependency honestlyfunc report(l VMLister) { l.List(ctx) }The price of duck typing, and a one-line fix
Implicit implementation isn’t free. The first cost is that nothing in the code tells you which interfaces a type satisfies. In Java you read the class declaration; in Go you search the whole project. That’s where the experience of deleting one method during a refactor and breaking some distant package comes from.
The second cost is accidental satisfaction. If the name and signature match, a type satisfies an interface even when the intent is completely different. The compiler can’t tell a Close() that flushes a cache from a Close() that drops a connection.
The idiomatic remedy for the first cost is this one line. It’s everywhere, but the first time I saw it I had no idea what it did.
var _ Saver = (*FileStore)(nil)
// How to read it:// (*FileStore)(nil) makes a nil pointer of type *FileStore (no real value needed)// var _ Saver = ... tries assigning it to Saver; compile error if it can't// assigning to _ leaves no variable behind and costs nothing at runtime🧪 Try it
What does the assertion actually prevent?
To see what this line is worth, I deliberately misspelled a method name on the implementation.
func (f *FileStore) Sava(v string) error { return nil }Without the assertion, the store package builds fine. The error shows up much later on the consumer side, at the line Run(&FileStore{}). With the assertion, the implementation file reports it immediately.
./store.go:9:15: cannot use (*FileStore)(nil) (value of type *FileStore) as Saver value in variable declaration: *FileStore does not implement Saver (missing method Save)The key was that it changes where the error shows up. Structural typing defers the satisfaction check until the type is used; this line pulls it forward to where the type is defined. It doubles as documentation and verification, so for exported types it’s worth adding.
The runtime cost: the price tag on flexibility
Finally, how the two-word layout shows up in performance. This isn’t advice to avoid interfaces. It’s about knowing the price tag on hot paths.
- Indirect call: read
itab.Fun[n]and jump. On its own, that’s a few nanoseconds at most. - Blocked inlining: this is the real cost. Without knowing the target, the compiler can’t inline, and once inlining is blocked, the escape analysis, constant propagation, and bounds-check elimination that would follow are blocked too.
- Boxing allocations: when you store a concrete value in an interface and it isn’t pointer-shaped, the runtime makes a heap copy (
runtime.convT*). Pointer-shaped types go straight intodata, and small integers like 0–255 reuse a static table in the runtime, so they don’t allocate. - Remedies: the compiler devirtualizes when it can, and turning on PGO (profile-guided optimization) widens that based on real call profiles.
When I wrote the same logic three ways, with a concrete type, generics, and an interface, generics over value types were about as fast as the concrete type, and only the interface was 2.4x slower and allocated once. Those numbers are what made the current advice, reach for generics first when you need abstraction, click for me.
Wrap-up
- Implicit implementation is a device for flipping dependencies. With the contract on the consumer side came retroactive satisfaction, small interfaces, and free mocks. Put interfaces in the implementation package and all of that disappears.
- An interface value is two words, (type, data). The nil trap, the cost of dynamic dispatch, and boxing allocations all come from that layout. It’s nil only when both slots are nil.
- The method set asymmetry is about addressability. Some values, like map elements, have no address, so
&can’t always be added, and Go chose compile-time consistency over unpredictable behavior. - Tooling covers the costs of structural typing.
var _ Iface = (*Impl)(nil)pins down the relationship in code for free, andgo vetcatches copied locks.
What I took away from writing this wasn’t four rules but one picture: two slots. Whenever I get confused, going back to that picture gives me the answer.