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 nil literally. 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.