Spectre
// PUBLISHED26.09.26
// TIME6 MINS
// TAGS
#GOLANG#INTERFACES#DEBUGGING#GOTCHA
// AUTHOR
Spectre Command

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.

Untitled
1type AppError struct {
2 Code int
3 Message string
4}
5
6func (e *AppError) Error() string {
7 return e.Message
8}
9
10func getError() error {
11 var err *AppError = nil
12 return err // You're returning a non-nil interface here
13}
14
15func main() {
16 err := getError()
17 fmt.Println(err == nil) // false
18 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:

Untitled
1func validateRequest(req Request) error {
2 var validationErr *ValidationError
3
4 if req.UserID == "" {
5 validationErr = &ValidationError{Field: "user_id", Message: "required"}
6 }
7
8 if req.Amount <= 0 {
9 validationErr = &ValidationError{Field: "amount", Message: "must be positive"}
10 }
11
12 return validationErr // Bug: typed nil when validation passes
13}

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:

Untitled
1err := 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:

Untitled
1if validationErr != nil {
2 return validationErr
3}
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.

Untitled
1// Wrong pattern
2func validateRequest(req Request) error {
3 var err *ValidationError
4 if req.UserID == "" {
5 err = &ValidationError{Field: "user_id"}
6 }
7 return err // typed nil on success
8}
9
10// Right pattern
11func validateRequest(req Request) error {
12 if req.UserID == "" {
13 return &ValidationError{Field: "user_id"}
14 }
15 return nil // clean interface nil
16}

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:

Untitled
1func validateTransaction(tx Transaction) error {
2 var txErr *TransactionError
3
4 if tx.Amount <= 0 {
5 txErr = &TransactionError{Code: "INVALID_AMOUNT"}
6 }
7
8 if tx.RecipientID == "" {
9 txErr = &TransactionError{Code: "MISSING_RECIPIENT"}
10 }
11
12 // Added during refactor but the return is still wrong
13 if txErr != nil {
14 log.Printf("validation failed: %s", txErr.Code)
15 }
16
17 return txErr
18}

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:

External links used:

  • None mechanism and examples are self-contained

Word count: ~1,280

// END_OF_LOGSPECTRE_SYSTEMS_V1

Is your current architecture slowing you down?

Stop guessing where the bottlenecks are. We partner with founders and CTOs to audit technical debt and execute zero-downtime system rewrites.

Book an Architecture Audit