Nil Interface vs Nil Pointer
An interface value in Go is a pair — a dynamic type and a dynamic value — and it equals
nilonly 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 isnil. That interface is not equal tonil, even though the pointer inside it is. This is the “typed nil” trap: a function returns what looks like “no error,” the caller writesif 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:
- The type word is set to the
itab(interface table) for the pair (interfaceerror, concrete type*MyError) — or to the bare*_typedescriptor for*MyErrorwhen the target is the empty interfaceany. This word is derived from the static type ofp, which the compiler knows at compile time. It does not depend on the runtime value ofp. - The data word is set to the value of
p— here, the nil pointer, i.e. the bit pattern0.
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 *MyError→error 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) // falseThe 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
- Interface Internals — the two-word representation this gotcha exploits
- Type Assertions and Type Switches — the inverse operation; how the dynamic type is read back
- Comparing Structs and Interfaces — the broader comparison-equality rules
- The Error Interface — why error-returning functions should use the
errortype - Error Wrapping and errors.Is errors.As — how
errors.Is(err, nil)interacts with typed nils - Reflection in Go —
reflect.Value.IsNil()as the escape-hatch detector - Writing to a Nil Map — a sibling “nil behaves surprisingly” gotcha
- Go Internals MOC — parent map (§13 Common Gotchas)