Articles / Go

  • go
  • error-handling
  • interfaces
  • nil
  • debugging

A nil *MyError Returned as error Is Not nil

This function returns "no error", and the caller sees an error anyway. Nothing is wrong with the comparison. The bug is in what got stored in the interface.

package main

import "fmt"

type MyError struct{ Msg string }

func (e *MyError) Error() string { return e.Msg }

func validate(s string) *MyError {
	if s == "" {
		return &MyError{Msg: "empty input"}
	}
	return nil
}

func run(s string) error {
	return validate(s)
}

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

That prints false, then *main.MyError <nil>. The input was fine, validate returned nil, and run still hands back a non-nil error.

An interface value is two words, not one

An interface value holds a type and a value. The interface is nil only when both are nil. When run returns validate(s), the nil *MyError is converted to error, so the interface now holds the type *MyError and a nil pointer. The type half is set, so err == nil is false.

The comparison err == nil is asking "is there no type and no value?". The answer is no: there is a type, and the value happens to be a nil pointer.

Why the output looks so daft

Printing it gives <nil>, which makes the bug look like a lie. Quick detour on why that works at all: fmt calls Error() on the value, and e.Msg dereferences a nil pointer. That panics, but fmt recovers and, when the receiver is a nil pointer, prints <nil> instead.

So you get a value that prints as nil, compares as non-nil, and would panic if you called Error() yourself. That last one is how this usually shows up in production: a nil pointer dereference in a logging line, far from the cause.

Where it comes from in real code

Nobody writes this deliberately. It sneaks in through a few routes:

  • A helper that returns a concrete error type, called from a function that returns error.
  • A variable declared as var err *MyError, assigned in some branches, then returned as error.
  • A struct field of a concrete pointer type, returned through an interface-typed method.
  • A generic or wrapper function that takes error and is passed a typed nil.

The second one is the classic. You build up a custom error in a loop, never assign it on the happy path, and return err at the end.

The fix: return error, not the concrete type

Declare functions to return error, and return a literal nil on success:

func validate(s string) error {
	if s == "" {
		return &MyError{Msg: "empty input"}
	}
	return nil
}

Here the nil is an untyped nil converted to error, so both halves of the interface are empty. The caller's check works as expected.

If you really need the concrete type inside the function, check before converting:

func run(s string) error {
	if e := validate(s); e != nil {
		return e
	}
	return nil
}

The e != nil check compares a pointer with nil, which is the comparison you meant. The final return nil produces a true nil interface.

Things that look like fixes but are not

You can make a nil-receiver-safe method, which stops the panic:

func (e *MyError) Error() string {
	if e == nil {
		return "<nil>"
	}
	return e.Msg
}

That hides the crash, not the bug: err != nil is still true, so the caller still takes the failure path with an error that says nothing. You can also use reflect.ValueOf(err).IsNil() at the call site. That works, but it pushes the problem onto every caller and it panics on a non-pointer type.

Fix it where the typed nil is created, not where it is noticed.

A rule that avoids the whole class

Exported and internal functions should return error, not *MyError. Callers who care about the concrete type can use errors.As:

var me *MyError
if errors.As(err, &me) {
	fmt.Println("validation failed:", me.Msg)
}

This also survives wrapping with %w, which a plain type assertion does not. The same rule applies to the loop case: keep the accumulator as error from the start, or only convert it at the return after an explicit nil check.

The language is consistent here, which is the annoying part. An interface holding a nil pointer is a legitimate value, and some designs rely on it (a nil receiver can be a valid empty implementation). The trap is only that error happens to be the interface where almost nobody wants it.