What Is a Nil Interface vs a Nil Concrete Value in Go

A nil interface holds no type or value, while a nil concrete value is a specific type that is nil but still makes the interface non-nil.

The empty box trap

You write a function that returns an error. You test it. The function completes successfully. Your if err == nil guard fails. You print the error and see an empty string. You stare at the code, convinced the logic is sound, until you realize the variable holds a typed nil pointer instead of a plain nil. This is the most common interface trap in Go. It trips up developers who expect nil to mean "nothing" regardless of type. Go disagrees.

How Go actually stores interfaces

An interface in Go is not a single value. It is a pair. The runtime stores two pieces of information inside every interface variable: the dynamic type and the dynamic value. Think of it like a shipping label attached to a package. The label says what kind of item is inside, and the package holds the actual item. A nil interface has no label and no package. It is completely empty. A nil concrete value has a label that says *int, but the package is empty. The label exists. The runtime sees the label and decides the interface is not nil.

This design keeps Go fast. The compiler does not need to guess what type you are storing. It records the type at the moment of assignment. That record stays with the value until the interface is reassigned or garbage collected. The tradeoff is that equality checks compare both parts of the pair. If either part is non-zero, the interface is non-nil.

Interfaces carry their type like a permanent tag. Strip the tag, and you get a true nil.

Minimal reproduction

Here is the smallest reproduction of the problem. We assign a nil pointer to an empty interface and test equality.

package main

import (
	"fmt"
	"reflect"
)

func main() {
	// Empty interface starts with no type and no value
	var i interface{}

	// Nil pointer has a type (*int) but no memory address
	var p *int = nil

	// Assigning p to i copies both the type and the nil value
	i = p

	// The == operator compares the type and value pair
	// Since the type is *int, the pair is not zero
	if i == nil {
		fmt.Println("i is nil")
	} else {
		fmt.Println("i is not nil, but holds a nil value")
	}

	// reflect.TypeOf returns the dynamic type stored in the interface
	// It returns nil only when the interface itself is completely empty
	if reflect.TypeOf(i) == nil {
		fmt.Println("Interface is truly nil")
	} else {
		fmt.Println("Interface holds a type:", reflect.TypeOf(i))
	}
}

Run it and watch the output. The type survives the nil value.

What happens under the hood

When the compiler sees i = p, it does not erase the type information. It builds an interface header in memory. The header contains two machine words. The first word points to the type descriptor for *int. The second word holds the data pointer, which is zero because p is nil. The == nil operator checks if both words are zero. They are not. The type word points to a valid type descriptor, so the comparison returns false.

This behavior is intentional. Go treats interfaces as first-class containers. If you put a *int inside, it stays a *int until you explicitly change it. The language does not automatically unwrap nil pointers into nil interfaces because that would require runtime type inspection on every assignment. That inspection would slow down every function call that returns an interface. Go prefers predictable performance over magical cleanup.

You can verify the memory layout by printing the interface with %T and %v. The type formatter shows *int. The value formatter shows <nil>. Both pieces of information are present. The runtime simply reports what it stores.

The type descriptor is the anchor. As long as it points somewhere, the interface is alive.

Realistic scenario: errors and JSON

This pattern shows up most often in error handling and serialization. Developers create custom error types and return a nil pointer when no error occurs. The function signature says error, which is an interface. The assignment stores the custom type alongside the nil pointer. The caller checks if err == nil and the check fails.

package main

import (
	"encoding/json"
	"fmt"
)

// CustomError implements the error interface
type CustomError struct {
	Code int
	Msg  string
}

// Error satisfies the error interface requirement
func (e *CustomError) Error() string {
	return fmt.Sprintf("code %d: %s", e.Code, e.Msg)
}

// DoWork returns a typed nil pointer instead of a plain nil
func DoWork() error {
	// Returning a typed nil preserves the *CustomError type
	// The interface pair becomes (*CustomError, nil)
	return (*CustomError)(nil)
}

func main() {
	err := DoWork()

	// This comparison fails because the interface holds a type
	if err == nil {
		fmt.Println("Success")
	} else {
		fmt.Println("Failed with:", err)
	}
}

The fix is straightforward. Return an untyped nil. The compiler converts the untyped nil to a nil interface automatically. The type slot stays empty. The value slot stays empty. The equality check passes.

func DoWork() error {
	// Untyped nil converts to a nil interface
	// Both the type and value slots become zero
	return nil
}

Go's error convention relies on this behavior. The standard library returns nil for success, not (*os.PathError)(nil). When you follow the convention, your if err != nil guards work exactly as expected. The community accepts the explicit nil return because it keeps interface equality predictable. A quick convention aside: nil is untyped until you assign it to a variable or return it from a function. The compiler infers the target type. If the target is an interface, untyped nil becomes a nil interface. Typed nil becomes a typed nil value inside the interface.

Return plain nil for success. Let the type system handle the rest.

Debugging and runtime panics

Typed nils cause trouble in three specific areas. JSON marshaling treats a typed nil pointer as null instead of omitting the field. Logging frameworks print the type name alongside an empty value, which confuses operators scanning dashboards. Reflection calls panic if you assume every interface value is a pointer.

If you try to call reflect.ValueOf(i).IsNil() on an interface that holds a non-pointer type, the runtime panics with reflect: Call of reflect.Value.IsNil on interface Value. The reflection package refuses to guess. You must verify the kind first.

val := reflect.ValueOf(i)
// Check the underlying kind before calling IsNil
// Pointers, slices, maps, channels, and functions support IsNil
if val.Kind() == reflect.Ptr && val.IsNil() {
	fmt.Println("Holds a nil pointer")
}

Another common mistake involves type assertions. You cannot use a type assertion to check for nil. The assertion v, ok := i.(*int) succeeds when i holds a typed nil *int. The ok flag returns true. The v variable becomes a nil pointer. You still have to check v == nil after the assertion. The assertion only verifies the type slot.

The compiler will not catch typed nil returns. It sees a valid type conversion and allows it. You get cannot use (*CustomError)(nil) (value of type *CustomError) as error value in return: *CustomError does not implement error only if the type lacks the method. If the method exists, the assignment is legal. The bug surfaces at runtime when equality checks fail.

Reflection requires explicit kind checks. Type assertions verify the label, not the contents.

When to use each approach

Use a plain nil return when a function succeeds and needs to signal the absence of an error or result. Use a typed nil pointer only when you are building a data structure that requires explicit nil markers for optional fields, and you control the entire codebase. Use reflection with a kind check when you are writing a generic library that must inspect arbitrary interface values. Use type assertions followed by a nil check when you need to extract a concrete pointer from an interface and handle the empty case. Use the == nil operator for standard error guards and simple interface comparisons.

Keep interfaces empty when they hold nothing. Extra type information is a feature, not a bug, but it demands intentional handling.

Where to go next