Skip to content

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.

casetabtype slotdatavalue sloterr == nil① true nilvar err errornilniltrue② holds nil pointervar p *MyErr*MyErrnilfalse③ real error&MyErr{}*MyErr0xc0…falseAn interface is nil only when both slots are
In ②, the value slot is empty but the type slot is filled, so it isn't nil
A helper that returns a concrete pointer type
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)
}
Output
false
my error

There’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.

Interface value = 2 words (16 B on 64-bit)tab*itabtype slotdataunsafe.Pointervalue slotitabone per (interface, concrete type)Inter *InterfaceTypeType *TypeHash uint32Fun[0] → (*FileStore).SaveFun[1] → …The actual valuepointer-shaped → stored directlyotherwise → address of a heap copy
An interface value is two words: a type slot (itab) and a value slot (data)

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.

iface · eface · itab (conceptual)
// 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.

Print both slots of the interface
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{})
}
Output
① true nil tab=0x0 data=0x0 == nil? true
② nil pointer tab=0x4a2e88 data=0x0 == nil? false
③ real value tab=0x4a2e88 data=0xc00001… == nil? false

The 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.

Countervalue type Tvalue receivers only✓ Show()✕ Inc()*Counterpointer type *Tboth receiver kinds✓ Show()✓ Inc()Method call · compilesd.Inc() → (&d).Inc()d is addressable, so the compiler adds &Interface assignment · compile errorvar i Incer = dCounter does not implement Incer(method Inc has pointer receiver)
The call d.Inc() compiles, but assigning the same d to Incer is a compile error
Value and pointer receivers
type Counter struct{ n int }
func (c Counter) Show() int { return c.n } // value: gets a copy
func (c Counter) BadInc() { c.n++ } // bumps only the copy, original unchanged
func (c *Counter) Inc() { c.n++ } // pointer: modifies the original
d := Counter{}
d.BadInc(); fmt.Println(d.n) // 0: only the copy changed
d.Inc(); fmt.Println(d.n) // 1: rewritten to (&d).Inc()
// method sets: the call works, the interface doesn't
type Incer interface{ Inc() }
var a Incer = &Counter{} // OK: Inc is in the method set of *T
var b Incer = Counter{} // error: Inc isn't in the method set of T

The spec’s rules fit in three lines.

  • The method set of T is the methods with receiver T.
  • The method set of *T is the methods with receiver T or *T, both.
  • Embedding promotes methods, but the receiver kind comes along. If Outer embeds Inner and Inner’s methods have pointer receivers, only *Outer satisfies 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.

Before · 4 bucketsAfter · some of 8bucket 0“a”0x…0010bucket 1“b”bucket 2—bucket 3“c”bucket 0—bucket 3“c”bucket 5“a”0x…00c8bucket 6“b”“a” moves → &m[“a”] can’t be allowedso m[“a”].Inc() can’t get an implicit & either
A map moves entries as it grows, so you can't take the address of m[k]

The diagram is conceptual. Since Go 1.24, maps use a group-based layout (Swiss tables), but entries can still move.

You can't call pointer methods on a map value
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 back
tmp := 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 value

Value 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.

A struct holding a lock
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 n
func (s *Safe) Good() { s.mu.Lock(); defer s.mu.Unlock(); s.n++ } // locks the original
go vet
$ go vet ./...
./main.go:7:7: Bad passes lock by value: main.Safe contains sync.Mutex

One 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.

Java · nominal typing · contract on the provider sideFileStoreimplementationSaverinterface · contractConsumerthe callerimplementsusesThe implementation has to import the contract tooGo · structural typing · contract on the consumer sideFileStoreno implementsConsumer packagetype Saver interfaceonly the methods it needsimportOne arrow only: the implementation never sees the contract
In Java the implementation imports the contract; in Go the contract lives only with the consumer

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.

The contract lives in the consumer package
// consumer package: all it needs is to save
type Saver interface{ Save(v string) error }
func Run(s Saver) error { return s.Save("data") }
// provider package: doesn't know Saver, doesn't import it
type 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 mock
type 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.

How the standard library composes: attach small pieces
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 12
func report(s VMService) { s.List() }

✓Split per consumer

type VMLister interface {
List(ctx context.Context) ([]VM, error)
}
// the signature states its dependency honestly
func 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.

Interface satisfaction assertion
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.

store.go: Save typed as Sava
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.

With the assertion
./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.

func Do(w *bytes.Buffer)w.Write(p)CALL bytes.Buffer.WriteStatic dispatchcan be inlinedno boxingfunc Do(w io.Writer)w.Write(p)MOV tab.Fun[0], AXCALL AXDynamic dispatchno inliningmay boxThe target sits in the itab, unknown at compile time
Calling through an interface puts the target in the itab, which blocks inlining
  • 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 into data, 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, and go vet catches 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.

Edit on GitHub
esc

Recent posts

↑↓ move↵ openOpen search page ›