Tricky parts of Golang. The Nil Interface Trap
Few bugs in Go are as disorienting as this one: you return nil from a function, check the result against nil, and the check fails. No type assertion panic, no runtime error — just a quiet wrong answer that sends you down a debugging rabbit hole.
This article breaks down why it happens, what Go is doing under the hood, and how to make sure you never ship this bug.
The Trap
Here is the canonical example. Suppose you have a validator that may or may not find a problem:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Message
}
func validate(input string) error {
var err *ValidationError // typed nil
if input == "" {
err = &ValidationError{Field: "input", Message: "must not be empty"}
}
return err // returning a typed nil
}
func main() {
err := validate("hello")
if err != nil {
fmt.Println("validation failed:", err) // THIS prints
} else {
fmt.Println("all good")
}
}
Running this prints validation failed: <nil> — validation passed, yet the error check fires, and the error message is <nil>.
This is not a compiler bug. It is working exactly as specified. The surprise comes from a mismatch between what we think an interface is and what it actually is.
What an Interface Really Is
In Go’s runtime, a non-empty interface value is a two-word struct — not a single pointer:
graph LR
A["iface"] --- B["type *itab"] --- C["data unsafe.Pointer"]
- type points to an
itab: a structure that records the concrete type and its method table. - data points to the actual value (or is the value, for small scalars).
A nil interface means both words are zero:
graph LR
A["nil interface"] --- B["type = nil"] --- C["data = nil"]
A typed nil — a nil pointer wrapped in an interface — means the type word is set, but data is nil:
graph LR
A["typed nil"] --- B["type = *ValidationError"] --- C["data = nil"]
The == nil comparison on an interface checks both words. If the type word is set, the interface is not nil, even when data is nil. That is why the check fires.
Why Go Works This Way
The design is intentional. Interfaces carry both what something is and where it is. Knowing the type at runtime is what makes polymorphic dispatch, reflection, and type assertions possible. If Go zeroed out the type word whenever data was nil, you would lose the ability to call methods on a nil receiver — a valid and often useful pattern in Go:
func (e *ValidationError) Error() string {
if e == nil {
return "no error"
}
return e.Field + ": " + e.Message
}
This method works perfectly on a nil *ValidationError. The runtime needs the type information to dispatch the call. Stripping it would break that.
Where This Bites You in Practice
Pattern 1: Returning a concrete type from an interface-typed function
The trap above. The fix is simple — return the untyped nil directly:
func validate(input string) error {
if input == "" {
return &ValidationError{Field: "input", Message: "must not be empty"}
}
return nil // untyped nil: both words zero
}
Never hold an intermediate typed variable and then return it. If you need to build up an error, check it before returning:
func validate(input string) error {
var err *ValidationError
if input == "" {
err = &ValidationError{Field: "input", Message: "must not be empty"}
}
if err != nil { // check the concrete type, not the interface
return err
}
return nil
}
Pattern 2: Storing errors in a slice, then returning the first
func runChecks(input string) error {
var errs []*ValidationError
if input == "" {
errs = append(errs, &ValidationError{Field: "input", Message: "required"})
}
// ... more checks ...
if len(errs) > 0 {
return errs[0]
}
return nil // correct: untyped nil
}
The same rule applies: return nil directly rather than a zero-value of a concrete type.
Pattern 3: Interface-wrapping in middleware or decorators
type Cache interface {
Get(key string) ([]byte, error)
}
type RedisCache struct {
addr string
}
func (r *RedisCache) Get(key string) ([]byte, error) { /* ... */ return nil, nil }
func newRedisCache(addr string) *RedisCache {
// imagine this fails to connect and returns nil
return nil
}
func buildCache(addr string) Cache {
c := newRedisCache(addr)
return c // typed nil — Cache is not nil!
}
Any caller checking if cache == nil will be wrong. The fix: check the concrete value before assigning it to the interface:
func buildCache(addr string) Cache {
c := newRedisCache(addr)
if c == nil {
return nil
}
return c
}
How to Detect It
At code review
Any function that returns an interface type and touches a named variable of a concrete pointer type before returning deserves a second look. If the last return statement returns a named variable rather than a literal nil, check whether that variable could be nil.
With go vet and staticcheck
The standard go vet does not catch this directly, but staticcheck has rule SA4023 (“impossible comparison of interface value with nil”) which catches some forms of this bug. Run it as part of your CI:
staticcheck ./...
At runtime with reflect
If you are debugging and genuinely need to know whether an interface’s underlying value is nil, you can inspect it:
func isNil(v any) bool {
if v == nil {
return true
}
val := reflect.ValueOf(v)
switch val.Kind() {
case reflect.Ptr, reflect.Interface, reflect.Slice,
reflect.Map, reflect.Chan, reflect.Func:
return val.IsNil()
}
return false
}
This is a debugging tool, not production logic. If you find yourself needing it in application code, the real fix is to restructure the return paths.
The Mental Model That Prevents the Bug
Whenever you write a function that returns an interface type, apply this rule:
Return
nilliterally. Never return a named variable of a concrete type when its value might be nil.
An interface is not “empty” just because its data is nil. It is only empty when you return the bare keyword nil — which has no type, and sets both words to zero.
If you keep this distinction clear — nil interface (no type, no data) versus typed nil (type set, data nil) — the trap becomes obvious before you write it.
Summary
| Value | Type word | Data word | == nil |
|---|---|---|---|
nil (untyped) |
zero | zero | true |
(*T)(nil) returned as interface |
*T itab |
nil | false |
&T{…} returned as interface |
*T itab |
pointer | false |
The nil interface trap is one of the few places in Go where the language’s explicit, two-word interface design leaks into everyday code. Once you see the memory layout, the behavior is unsurprising — but until then, it looks like the language is lying to you.
