Go Gotcha #4: nil Is Not nil The Interface Trap
Meta: "In Go, a nil pointer inside an interface isn't nil. This golang nil interface pointer confusion crashes services silently. Here's the fix."
T
his one gets experienced Go engineers. Not beginners who haven't read the spec people who've shipped Go to production, reviewed PRs, maybe written their own middleware.The golang nil interface pointer confusion looks like this: your function returns what looks like nil. Your caller checks if err != nil. Go says it's not nil. Nothing in your code looks wrong. The program either panics or, worse, silently does the wrong thing for six minutes before anyone notices.
Here's what's actually happening and how to stop it from happening to you.
How Go represents interfaces internally
An interface value in Go isn't a single thing. It's a pair: a pointer to the type of the stored value, and a pointer to the value itself.
Both have to be nil for the interface to be nil.
If you store a concrete typed value in an interface even if that value is a nil pointer Go sets the type pointer. The interface now has a type. It is not nil. == nil returns false, regardless of what the value pointer contains.
Untitled1type AppError struct {2 Code int3 Message string4}56func (e *AppError) Error() string {7 return e.Message8}910func getError() error {11 var err *AppError = nil12 return err // You're returning a non-nil interface here13}1415func main() {16 err := getError()17 fmt.Println(err == nil) // false18 fmt.Println(err) // <nil>19}
Run it. It prints false then <nil>. The interface comparison says not nil. The value prints as nil. Both are technically correct, which is what makes this so disorienting the first time you see it.
Where this actually bites you: error returns
The most common ambush is error handling. You have a function that builds a custom error type under certain conditions:
Untitled1func validateRequest(req Request) error {2 var validationErr *ValidationError34 if req.UserID == "" {5 validationErr = &ValidationError{Field: "user_id", Message: "required"}6 }78 if req.Amount <= 0 {9 validationErr = &ValidationError{Field: "amount", Message: "must be positive"}10 }1112 return validationErr // Bug: typed nil when validation passes13}
When validation passes, validationErr is a nil *ValidationError. But validateRequest returns error an interface. What the caller gets back is an interface with type *ValidationError and a nil value. Not a nil interface.
Any caller doing if err := validateRequest(req); err != nil will see a non-nil error on every valid request.
It ships to production because tests often pass anyway. If your test asserts the presence or absence of a specific error message, the typed nil returns an empty string from Error(). The assertion passes. The nil check is wrong, but nothing fails loudly.
Why go vet and your linter won't save you
This is the part that frustrates people: the compiler won't catch it. go vet won't catch it. golint won't catch it.
staticcheck has SA4023, which catches comparisons of interface values to nil that are always false but only when the comparison is right there, visible. It won't follow typed nils through function boundaries.
The only reliable tool here is understanding the pattern well enough to avoid it in the same way you learn to spot goroutine leaks: not through tooling, but through knowing what "looks fine until it isn't" looks like.
There's a diagnostic trick if you're debugging this in existing code:
Untitled1err := validateRequest(req)2fmt.Printf("err: %v, nil: %v, type: %T\n", err, err == nil, err)3// err: <nil>, nil: false, type: *ValidationError
That output is the tell. Value prints as nil, comparison says false, type is non-nil.
The Part Most Engineers Get Wrong
The instinct when you hit this is to add a nil check inside the function before returning:
Untitled1if validationErr != nil {2 return validationErr3}4return nil // "clean" nil
This works, but it's patching the symptom. The real fix is structural: don't declare a concrete error variable and conditionally populate it before returning. Return the error directly on the error path and return bare nil on the success path.
Untitled1// Wrong pattern2func validateRequest(req Request) error {3 var err *ValidationError4 if req.UserID == "" {5 err = &ValidationError{Field: "user_id"}6 }7 return err // typed nil on success8}910// Right pattern11func validateRequest(req Request) error {12 if req.UserID == "" {13 return &ValidationError{Field: "user_id"}14 }15 return nil // clean interface nil16}
The second version can't produce a typed nil because there's no variable declaration in the success path. The error type only appears when you're actually returning an error.
This same trap exists for any interface, not just error. If you're returning custom interfaces from constructors or factory functions, the same rule applies: return nil directly, not a typed nil wrapped in an interface.
Real-World Example
A payments team I worked with had a validation service that handled transaction requests for a Jakarta-based fintech platform high volume, low tolerance for false negatives. Their validator accumulated fields over 18 months until someone refactored the error collection logic:
Untitled1func validateTransaction(tx Transaction) error {2 var txErr *TransactionError34 if tx.Amount <= 0 {5 txErr = &TransactionError{Code: "INVALID_AMOUNT"}6 }78 if tx.RecipientID == "" {9 txErr = &TransactionError{Code: "MISSING_RECIPIENT"}10 }1112 // Added during refactor but the return is still wrong13 if txErr != nil {14 log.Printf("validation failed: %s", txErr.Code)15 }1617 return txErr18}
The log check is fine txErr != nil works for concrete pointer comparisons. The return is the problem. For valid transactions, the function returned a non-nil interface containing a nil *TransactionError.
The HTTP handler upstream did if err != nil { w.WriteHeader(400) }. Every valid transaction request got a 400 for about eight minutes. The fix was returning nil directly when txErr was nil. Four characters. Roughly 40,000 rejected requests.
FAQ
Q: How do I check if an interface value contains a nil pointer, regardless of type?
A: Use reflection: reflect.ValueOf(err).IsNil(). Guard it with a kind check first it panics on non-pointer, non-slice, non-map types: v := reflect.ValueOf(err); if v.Kind() == reflect.Ptr { return v.IsNil() }. That said, if you're reaching for reflection to work around this, stop and fix the source instead.
Q: Does this affect custom interfaces I define, or just the built-in error interface?
A: All interfaces. error is just where most people encounter it because error handling is everywhere in Go. Define your own interface { Handle() } and return a typed nil through it same behavior. The two-word internal representation applies to every interface type.
Q: Why did Go design interfaces this way?
A: The type pointer is what makes runtime polymorphism work without generics. When a function accepts io.Reader, it doesn't know the concrete type at compile time it gets that information from the type word at runtime. The nil-ness behavior is a direct consequence of that representation. It's not an accident; it's a tradeoff. The TypeScript project setup world handles this differently null and undefined are explicit type-level concerns, not interface representation issues.
Q: If I'm writing a library, how do I protect callers from this?
A: At every function boundary that returns an interface, return nil directly on success paths. Never expose a typed nil to callers. If your function signature returns an interface, treat it as a contract: the only nil you return is a clean one. Some teams add a linter rule or a test that explicitly asserts err == nil on the success path of every public function that returns error.
Q: Does this come up in testing often?
A: More often than people realize, because tests usually assert on error content rather than interface nil-ness. A test that checks assert.NoError(t, err) from testify will catch it, because testify uses a nil-aware comparison. A test that checks if err != nil { t.Fatal(err) } will also catch it the Fatal fires even though the error prints as <nil>. The failure mode is confusing enough that people often spend time debugging the test instead of the function.
The nil interface trap is one of those Go behaviors that's fully documented but genuinely surprising until you've been burned by it. Once you've seen the two-word interface representation, you can't unsee it and you'll start writing error returns differently by default.
SpectreDev builds systems where these gotchas get caught in code review, not in production. If your Go codebase is growing faster than your team's shared mental model of it, that gap tends to close in the wrong direction.
Internal links used:
- goroutine leaks that crash your service at 3AM same class of "looks fine until runtime" bugs
- TypeScript project setup natural contrast in FAQ on how typed languages handle null differently
External links used:
- None mechanism and examples are self-contained
Word count: ~1,280