Skip to content
gohacks.dev
Basics

The Nil Interface Trap That Bites Every Go Developer

A nil pointer stored in an interface is not a nil interface, so a function can return an error that is not nil. See the bug, the diagram and the fix.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

Every language has one rule that surprises people and then never surprises them again. In Go it is this one: you can return nil from a function and the caller can still see a non-nil error. It is not a compiler bug. It is a direct result of how interface values are built, and once you see the layout, the behavior is predictable. The trap shows up in code review once a month, usually as an error check that always fires, and in production as a request that fails with an error message that says nothing went wrong. This article reproduces it, draws the memory layout in words, and gives the fix and the two checks that find it. It follows interfaces in Go in the Go basics path and depends on how pointers work.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The bug in twelve lines

A function is declared to return error. Inside it keeps a typed variable, a pointer to a custom error struct, fills it in on failure, and returns it either way:

main.goGo
package main

import "fmt"

type MyError struct{ Code int }

func (e *MyError) Error() string { return "my error" }

func mayFail(fail bool) error {
	var e *MyError
	if fail {
		e = &MyError{Code: 7}
	}
	return e
}

func main() {
	err := mayFail(false)
	fmt.Println(err == nil)
	fmt.Println(err)
	fmt.Printf("%T %v\n", err, err == nil)

	if err != nil {
		fmt.Println("caller thinks the call failed")
	}
}
Text
false
my error
*main.MyError false
caller thinks the call failed

mayFail(false) should report success. It returns a variable e that is a nil *MyError, and the comparison err == nil is false. The caller takes the failure branch and prints its message. The second line is the part that hides the bug in practice: fmt.Println(err) printed my error, because the Error method has a pointer receiver that does not read any field, so it runs fine on a nil pointer. In a log line it looks like a real error with no detail, and the author starts hunting for what failed when nothing did.

What an interface value is

An interface variable is two words. One points to the dynamic type, and the other holds the value, or a pointer to it. The interface is nil only when both words are zero. When you assign a nil *MyError to an error, the type word is set to *MyError and the value word is nil. The pair is (*MyError, nil), which is not the zero pair.

What you assignedType wordValue worderr == nil
Nothing, or the literal nilnonenonetrue
&MyError{}*MyErrorpointer to a structfalse
A nil *MyError variable*MyErrornilfalse

Comparing an interface to nil checks the whole pair, which is correct for an interface and wrong for what you meant. You were thinking in terms of the pointer, and the language is looking at the interface. The %T verb prints the type word, and that line of the output, *main.MyError, is the diagnostic that gives the game away.

Where it happens in real code

The shape is always the same: a concrete pointer type flows into an interface-typed result. Four common forms:

A typed error variable that is returned unconditionally. The example above. It appears when a helper builds a custom error and a caller returns it on every path.

A function returning a pointer that is passed to an interface parameter. func process(r io.Reader) called as process(f) where f is a nil *os.File passes a non-nil interface and panics later inside process, with a trace that does not mention the caller.

A struct field of interface type assigned from a nil pointer. A logger Logger field set from a var l *FileLogger that failed to open leaves the field non-nil, and if s.logger != nil guards nothing.

Returning from a branch with a deferred function. A named result err error that a defer assigns from a typed pointer variable can produce the same pair.

The pattern to remember is the type of the expression being converted. Converting a value of pointer type to an interface can never produce a nil interface, whatever the pointer holds.

The fix: return nil, not a nil variable

The cure is to keep the concrete type out of the return statement on paths that succeed:

main.goGo
package main

import (
	"errors"
	"fmt"
)

type MyError struct{ Code int }

func (e *MyError) Error() string { return fmt.Sprintf("my error %d", e.Code) }

func mayFail(fail bool) error {
	if fail {
		return &MyError{Code: 7}
	}
	return nil
}

func main() {
	err := mayFail(false)
	fmt.Println(err == nil)

	err = mayFail(true)
	var me *MyError
	fmt.Println(err != nil, errors.As(err, &me), me.Code)
}
Text
true
true true 7

Return &MyError{...} directly on failure and the literal nil on success. The untyped nil in an error result position produces the zero pair, so the comparison is true. It also lets errors.As find the concrete type for callers that want its fields, which is the right way to get from the interface back to the struct, as errors Is and errors As shows. Notice also that I changed Error to read e.Code. With a pointer receiver that dereferences, the bug version would have panicked in fmt.Println instead of printing quietly, and a loud failure is easier to find than a quiet one.

When the typed variable is unavoidable, convert at the boundary:

Go
func finish(e *MyError) error {
	if e == nil {
		return nil
	}
	return e
}

That helper is a small tax, and I would rather restructure the function so no typed variable crosses the interface boundary.

Detecting it

Two checks find the trap in running code. Print the type with the %T verb: it shows *main.MyError for a typed nil and <nil> for a real one. And when you need to ask whether the pointer inside an interface is nil, reflection does it:

main.goGo
package main

import (
	"fmt"
	"reflect"
)

type MyError struct{}

func (*MyError) Error() string { return "e" }

func isNilInside(v any) bool {
	if v == nil {
		return true
	}
	rv := reflect.ValueOf(v)
	switch rv.Kind() {
	case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan, reflect.Interface:
		return rv.IsNil()
	}
	return false
}

func main() {
	var typedNil *MyError
	var plain error
	var err error = typedNil

	fmt.Println(err == nil, isNilInside(err))
	fmt.Println(plain == nil, isNilInside(plain))
	fmt.Println(isNilInside(&MyError{}), isNilInside(42))
}
Text
false true
true true
false false

A helper like isNilInside is a workaround and should live in a test helper, not in business code. If you find yourself needing it in production code, the interface is receiving values it should not. Static analysis helps a little: the nilness analyzer in golang.org/x/tools reports some definite nil dereferences, but not this conversion, so a code review habit and a test that checks the success path with err != nil are more reliable. A table-driven test whose first case is "no error returned" catches this class instantly, because it fails on the first run.

Why Go works this way

It is tempting to say the language should treat a nil pointer in an interface as a nil interface. The reason it does not is that a nil pointer can be a perfectly valid receiver. A method on a nil *Node that returns zero for an empty list, or a nil *bytes.Buffer handled by Len, is legal and useful, and it requires the interface to carry the type so the call can be dispatched to that method. Treating the interface as nil would drop the type and make such calls impossible. The cost of this design is the trap, and the benefit is nil receivers that work. The Go FAQ entry on this exact question gives the language designers' answer, and it uses the same shape of example.

Where the trap is not your problem

If your functions return concrete types, as the guidance in accept interfaces, return structs recommends, the conversion to an interface happens in the caller, usually from a value you just checked, and the trap rarely arises. The conversions to watch for are the ones into error, io.Reader, fmt.Stringer and your own consumer interfaces. And if all of your error types are values and not pointers, var e MyError has no nil state, so the whole problem disappears. For the language rules behind it, the specification on comparison says an interface value is equal to nil only if it has no dynamic type.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.