The Biggest Interface Gotcha

Nil Interface vs Nil Pointer

A nil interface is not nil because it contains a type descriptor, while a nil pointer does not; check the underlying value to detect nil pointers inside interfaces.

The empty box that isn't empty

You write a function that returns an error. You call it. You check if err == nil. The condition fails. You print the error and it says <nil>. You stare at the screen. The value is clearly nothing, but Go refuses to treat it as nothing. This is the most common interface trap in the language. It catches everyone at least once.

What an interface actually holds

Go interfaces are not magic containers. They are structured values with exactly two slots. The first slot stores a type descriptor. The second slot stores the actual data. When you declare var i any, both slots are zeroed out. The type is nil. The data is nil. That is a truly empty interface.

Assign a concrete value to that interface and the compiler fills both slots. The type slot gets the concrete type. The data slot gets a pointer to the value. Now assign a nil pointer to the interface. The type slot fills up with the pointer type. The data slot stays nil. The interface is no longer empty. It carries a type label with no payload.

Think of a labeled storage locker. An empty locker has no label and no contents. A locker labeled "Winter Gear" that contains nothing is still labeled. Checking if the locker is empty requires looking at the label first. Go's == nil operator checks both slots. If the type slot is filled, the comparison fails.

An interface is a pair, not a box.

The minimal reproduction

Here is the smallest program that triggers the mismatch. It assigns a nil pointer to an interface and compares both to nil.

package main

import "fmt"

func main() {
    // A concrete pointer type, explicitly set to nil
    var ptr *int = nil

    // Store the nil pointer inside an interface value
    var iface any = ptr

    // The interface holds a type (*int) and a value (nil)
    // The type slot is non-nil, so the whole interface is non-nil
    fmt.Println(iface == nil) // false

    // The pointer itself has no type slot to check
    fmt.Println(ptr == nil)   // true
}

The output confirms the split. The pointer is nil. The interface is not. The compiler allows this assignment because *int implements any. The type system sees a valid conversion. The runtime sees two different memory layouts.

How the comparison actually works

When you write iface == nil, the compiler generates a check against the interface's internal representation. It verifies that the type descriptor is nil AND the data pointer is nil. Both must be zero for the expression to evaluate to true.

Storing a typed nil pointer changes the type descriptor. The descriptor now points to the runtime type information for *int. The data pointer remains zero. The logical AND fails on the first condition. The expression returns false.

You can see the hidden type by printing it. The %T verb reads the type descriptor directly from the interface value.

package main

import "fmt"

func main() {
    var ptr *int = nil
    var iface any = ptr

    // %T reads the type descriptor slot, bypassing the value check
    // This reveals the hidden baggage inside the interface
    fmt.Printf("Type: %T, Value: %v\n", iface, iface)
}

The output shows Type: *int, Value: <nil>. The type slot is populated. That is why the equality check fails. The interface remembers what kind of nil it holds.

The type slot never lies. Check it before you check the value.

Where this breaks in real code

This pattern rarely appears in toy examples. It shows up in error handling and API boundaries. Functions that return error are the usual suspects. The error type is just an interface with a single Error() string method.

Suppose you define a custom error type. You write a helper that returns nil when everything succeeds. You accidentally return a nil pointer to your custom error type instead of a bare nil.

package main

import "fmt"

// CustomError implements the error interface
type CustomError struct {
    code int
}

// Error satisfies the error interface contract
func (c *CustomError) Error() string {
    return fmt.Sprintf("error code: %d", c.code)
}

// Helper returns an error interface
func doWork() error {
    // Accidental typed nil return
    var err *CustomError = nil
    return err // Returns interface{type: *CustomError, value: nil}
}

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

The program prints Failed with: <nil>. The caller expects a clean success path. It gets a non-nil interface that prints as empty. The if err != nil convention breaks down because the return value carries a hidden type. Go developers accept the verbose error checking boilerplate precisely because it makes the unhappy path visible. This bug hides behind that convention.

Fix it by returning nil directly. Go converts a bare nil to a completely empty interface. The type slot stays zeroed.

func doWork() error {
    // Return a bare nil, not a typed nil pointer
    // The compiler converts this to an empty interface pair
    return nil
}

The compiler handles the conversion automatically. The interface receives (nil, nil). The equality check works as expected.

Return nil, not nil *Error. The caller will thank you.

Extracting the value safely

When you cannot control the return value, you must extract the concrete type before checking for nil. Type assertions pull the value out of the interface and restore its original type. Once the value is back to a concrete pointer, == nil works normally.

package main

import "fmt"

func main() {
    var ptr *int = nil
    var iface any = ptr

    // Extract the concrete pointer from the interface
    // The comma-ok idiom prevents panics on type mismatch
    p, ok := iface.(*int)

    if ok && p == nil {
        fmt.Println("Interface holds a nil *int")
    }
}

The ok flag confirms the type matches. The p == nil check examines the actual pointer. Both conditions must pass. This pattern is safe and explicit. It tells the reader exactly what you expect inside the interface.

Type assertions are the standard way to unpack interfaces. They cost a small runtime check but guarantee type safety. The community accepts the boilerplate because it makes the extraction visible. If you forget the comma-ok idiom and the types do not match, the runtime panics with interface conversion: interface {} is *int, not *string. Always use the two-value form in production code.

Common pitfalls and debugging

This bug survives code review because the code compiles cleanly. The type system validates the assignment. The runtime silently stores the type descriptor. You only notice when equality checks fail or when fmt.Println outputs <nil> instead of skipping the value.

The compiler will not warn you about returning a typed nil from an interface function. It treats *CustomError and nil as compatible with the error interface. Linters like staticcheck catch this pattern and flag it as a potential logic error. Running go vet or a linter in your CI pipeline catches most accidental typed nil returns.

When debugging, print the type descriptor. If a value looks empty but fails a nil check, the type slot is almost certainly populated. Use fmt.Printf("%T", value) to reveal it. If you see a concrete type, you know the interface is carrying baggage.

Print the type when the value looks empty. The descriptor always tells the truth.

When to check what

Use a direct nil comparison when you expect the interface to be completely empty and untyped. Use a type assertion followed by a nil check when the interface might hold a typed nil pointer from a downstream call. Return a bare nil from functions that return interfaces to avoid the mismatch entirely. Use fmt.Printf("%T", value) when debugging to reveal the hidden type descriptor before writing equality checks.

Interfaces carry baggage. Unpack the type before you judge the value.

Where to go next