Nil Interface vs Nil Pointer

An interface value in Go is a pair — a dynamic type and a dynamic value — and it equals nil only when both halves are unset. Storing a nil pointer into an interface produces an interface whose type half is set (to, say, *MyError) and whose value half is nil. That interface is not equal to nil, even though the pointer inside it is. This is the “typed nil” trap: a function returns what looks like “no error,” the caller writes if err != nil, and the branch fires anyway. The Go FAQ documents it directly under “Why is my nil error value not equal to nil?” (go.dev/doc/faq), and the spec’s comparison rules pin down the exact semantics (go.dev/ref/spec).

This note is a focused deep-dive on one gotcha. The general two-word representation of interfaces — the itab/*_type plus data pointer — lives in Interface Internals; this note assumes that mechanism and traces why it makes typed nils behave the way they do.

Mental Model

The single most important fact: nil is not a value an interface “holds.” nil is the state of an interface where neither the type slot nor the value slot has been set. A naive mental model imagines an interface as a box that can contain a pointer, and nil as “the box contains a nil pointer.” That model is wrong, and the typed-nil bug is exactly the gap between it and reality.

The correct model is a two-field record. Call the fields T (dynamic type) and V (dynamic value). An interface is nil if and only if T is unset and V is unset — written schematically (T=nil, V=nil). The moment you assign a concrete value to an interface — any concrete value, including a nil pointer — the runtime records that value’s static type in T. From then on T is non-nil, so the interface as a whole is non-nil, regardless of what V is.

flowchart TD
    subgraph A["var err error  (untouched)"]
        A1["T = nil"]
        A2["V = nil"]
        A3["err == nil  -->  TRUE"]
    end
    subgraph B["var p *MyError = nil; err = p"]
        B1["T = *MyError  (SET!)"]
        B2["V = nil"]
        B3["err == nil  -->  FALSE"]
    end
    A -. "assigning a typed nil pointer<br/>sets the type half" .-> B
    style A3 fill:#1b5e20,color:#fff
    style B3 fill:#7f1d1d,color:#fff
    style B1 fill:#7f1d1d,color:#fff

Diagram: the two interface slots in two states. On the left an interface variable that was never assigned a concrete value — both slots empty, so == nil is true. On the right the same variable after a nil *MyError is assigned: the value slot is still nil but the type slot now names *MyError, so == nil is false. The insight: it is the type slot, not the value slot, that decides nil-ness.

Mechanical Walk-through

What the spec actually says

The spec’s comparison rules state: “Two interface values are equal if they have identical dynamic types and equal dynamic values, or if both are nil (go.dev/ref/spec). The phrase “or if both are nil” is shorthand for the FAQ’s precise statement: “An interface value is nil only if the V and T are both unset, (T=nil, V is not set). In particular, a nil interface will always hold a nil type” (go.dev/doc/faq).

Equality between an interface and the untyped predeclared identifier nil is therefore a check on both halves. err == nil compiles, roughly, to “is the type word zero?” — the runtime does not even need to look at the data word, because a zero type word is only ever produced by a never-assigned (or explicitly nil-assigned) interface.

The conversion that sets the type slot

When you write var err error = p where p has concrete type *MyError, the compiler emits an interface conversion. The conversion’s job is to populate both interface words:

  1. The type word is set to the itab (interface table) for the pair (interface error, concrete type *MyError) — or to the bare *_type descriptor for *MyError when the target is the empty interface any. This word is derived from the static type of p, which the compiler knows at compile time. It does not depend on the runtime value of p.
  2. The data word is set to the value of p — here, the nil pointer, i.e. the bit pattern 0.

Because step 1 keys off the static type, assigning p always yields a non-zero type word, even when p == nil. The interface is now (T=*MyError, V=nil). It is non-nil. The pointer it carries is nil. Both statements are simultaneously true, and that is the entire bug.

The FAQ’s canonical example (go.dev/doc/faq):

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = ErrBad
    }
    return p // Will always return a non-nil error.
}

If bad() is false, p stays nil — but return p still performs the *MyErrorerror conversion, producing (T=*MyError, V=nil). The caller’s if err != nil sees a non-nil interface and treats success as failure.

Where the type half “comes from” with a nil pointer

It is worth dwelling on this because it is unintuitive. A nil pointer carries no runtime type information — it is just the address 0. So how does the interface get a type? The answer: it does not get the type from the value; it gets it from the expression’s static type, decided by the compiler. The interface conversion is a compile-time-typed operation. return p in a function with static type *MyError for p will always embed *MyError in the type slot. The runtime value of p is irrelevant to which itab is chosen. (See Type Assertions and Type Switches for the inverse operation — pulling the dynamic type back out.)

Code Examples with Line-by-Line Commentary

Example 1 — the classic failure

package main
 
import "fmt"
 
type MyError struct{ msg string }
 
func (e *MyError) Error() string { return e.msg }   // pointer-receiver method
 
func doWork(fail bool) error {                       // returns the *interface* error
    var result *MyError                              // result is a nil *MyError
    if fail {
        result = &MyError{"it broke"}
    }
    return result                                    // BUG: converts *MyError -> error
}
 
func main() {
    err := doWork(false)                             // we asked it NOT to fail
    fmt.Println(err == nil)                          // prints: false  <-- the trap
    fmt.Printf("%T %v\n", err, err)                  // prints: *main.MyError <nil>
}

Line 9 declares result as a nil *MyError. Line 13 is the defect: return result converts the concrete pointer to the interface type error, embedding *main.MyError in the type slot. Line 18 — the caller’s nil check — sees a non-nil interface (type slot set) and prints false. Line 19 is the diagnostic smoking gun: %T prints the dynamic type *main.MyError, proving the type slot is populated, while %v prints <nil>, proving the value slot is nil. When you see a non-nil error whose %v is <nil>, you have found a typed nil.

Example 2 — the fix: return an explicit nil

func doWork(fail bool) error {
    if fail {
        return &MyError{"it broke"}                  // success path never touches *MyError
    }
    return nil                                       // explicit untyped nil -> (T=nil, V=nil)
}

return nil on line 5 returns the untyped nil. There is no concrete type in the expression, so no interface conversion stamps a type word — the interface is genuinely (T=nil, V=nil). The caller’s err == nil is now true on the success path. The rule the FAQ gives: “functions that return errors always [should] use the error type in their signature … rather than a concrete type” — and then never return a concrete-typed nil; return the literal nil.

Example 3 — the assignment-to-interface variant

func collect() error {
    var errs []error
    // ... nothing appended ...
    var combined *MultiError = buildMulti(errs)      // buildMulti returns nil when errs empty
    return combined                                  // BUG again
}

The same trap, one layer removed. buildMulti legitimately returns a nil *MultiError, but the moment that nil flows through return combined it becomes (T=*MultiError, V=nil). The fix is the same — check and return literal nil:

func collect() error {
    var errs []error
    if len(errs) == 0 {
        return nil
    }
    return buildMulti(errs)
}

Example 4 — typed nil in a struct field / any

type Response struct{ Err error }
 
var p *MyError                       // nil
r := Response{Err: p}                // r.Err is now (T=*MyError, V=nil) -- non-nil
fmt.Println(r.Err == nil)            // false
 
var x any = p                        // empty interface, same trap
fmt.Println(x == nil)                // false

The trap is not specific to error. Any interface-typed destination — a struct field, a function parameter, a slot in a []any, a map value — converts an assigned concrete nil into a non-nil interface. any (the empty interface) is no exception: line 7 prints false for the same reason.

Failure Modes and How to Diagnose Them

Symptom: an error handler fires when nothing went wrong. The classic. Print %T on the error: if it shows a concrete pointer type while %v shows <nil>, it is a typed nil. The fix is upstream — the function producing the error returns a concrete-typed nil instead of literal nil.

Symptom: errors.Is(err, nil) behaves oddly. errors.Is(err, target) with target == nil reduces (since Go 1.13’s errors semantics) to err == nil on the interface. A typed nil makes that comparison false, so errors.Is(err, nil) is false for a typed-nil err. Diagnose the same way. See Error Wrapping and errors.Is errors.As.

Symptom: a check on any from JSON / reflection. Decoders and reflection routinely hand back any. If something produced a typed-nil and stuffed it into an any, the == nil check fails. reflect.ValueOf(x).IsNil() will report true for a typed nil — that is the deliberate escape hatch (next section).

Why go vet does not catch it in general. Whether return result is a bug depends on whether result can be nil at that point — a data-flow question that is undecidable in general. The targeted nilness analyzer in golang.org/x/tools does not catch the typed-nil-return case at all: its package doc states it “inspects the control-flow graph of an SSA function and reports errors such as nil pointer dereferences and degenerate nil pointer comparisons” (nilness/doc.go), and reading its implementation (nilness.go) confirms it reports nil dereferences, tautological/impossible nil comparisons, and panic(nil) — but has no reporting path for an SSA MakeInterface of a nil concrete pointer (the operation that mints a typed nil). So neither the intra-function case (Example 1) nor the cross-function case (Example 3) is flagged. Go 1.26 revamped go fix onto the analysis framework but added no analyzer specifically for this either (go.dev/doc/go1.26). Treat the typed-nil return as a code-review and discipline problem, not a tooling-solved one.

Common Misunderstandings

“It only happens with error.” No — error is just the most-reported case because every Go program returns errors. It happens with every interface type. (Example 4.)

“It happens because the pointer is nil.” No — it happens because a concrete type was assigned. A non-nil *MyError assigned to error is also (T=*MyError, V=0xc0000...) — also non-nil — but that is correct, so nobody notices. The nil case is only surprising because the programmer wanted nil-ness and the value half cooperated while the type half did not.

“Comparing the interface to a typed nil would work.” Partially, and it is a code smell. err == (*MyError)(nil) is true for a typed-nil err because both sides now carry (T=*MyError, V=nil) and the spec’s “identical dynamic types and equal dynamic values” rule is satisfied. But this requires the caller to know and name the concrete type, which defeats the purpose of the error interface. The real fix is upstream: never produce the typed nil.

Alternatives and When to Choose Them

Return literal nil (the standard fix). Structure functions so the success path executes return nil, never return someConcreteNilPointer. This is the idiom the FAQ prescribes and what the standard library does — os.Open returns error, and on success returns literal nil, never a nil *os.PathError (go.dev/doc/faq).

Never store the concrete pointer in an interface variable until you know it is non-nil. If you must build a concrete error value conditionally, keep it in a concrete-typed variable and only convert at the return:

func f() error {
    var e *MyError
    if problem { e = &MyError{...} }
    if e != nil { return e }   // concrete-typed nil check — correct
    return nil
}

The e != nil check on a *MyError variable compares pointers, not interfaces, so it is honest.

reflect.Value.IsNil() as a last-resort detector. When you are handed an any of unknown provenance and must distinguish a genuine nil from a typed nil, v := reflect.ValueOf(x); v.Kind() == reflect.Ptr && v.IsNil() detects the typed nil. This is the right tool inside generic plumbing (logging, serialization), but it is a smell in ordinary application code — fix the producer instead. See Reflection in Go.

Generics do not solve it. A generic function func f[T any]() T returning the zero value of a pointer type parameter returns a genuine nil pointer; but the moment that flows into an error or any, the conversion re-introduces the type slot. Generics change where the conversion happens, not whether it happens.

Production Notes

The typed-nil bug is endemic enough that it appears in the official FAQ — a short list reserved for the questions Go’s authors field most often. Real-world incidents almost always follow the same shape: a helper function is declared to return a concrete error type (e.g. func validate() *ValidationError), a caller assigns the result to an error variable or passes it where an error is expected, and a downstream if err != nil misfires. The structural fix the Go team recommends — declare error-returning functions with the error type in the signature — works precisely because it forces the conversion to happen at the return statement inside the helper, where the author can see the success path and write return nil explicitly.

A subtler production hazard: typed nils survive serialization boundaries quietly. A struct with an error-typed field set to a typed nil will, under many encoders, be treated as “has an error.” Middleware that inspects resp.Err != nil to decide HTTP status codes can return 500 for a perfectly successful request. The diagnosis is always the same — log %T alongside %v; a concrete type next to <nil> is the fingerprint.

See Also