Skip to content

Fix: Go Interface Nil Panic — Non-Nil Interface Holding a Nil Pointer

FixDevs ·

Quick Answer

How to fix the Go interface nil trap — understanding non-nil interfaces with nil pointers, detecting the issue, error interface patterns, and designing APIs to avoid the pitfall.

The Problem

A nil check passes but the code panics on the next line:

func getUser(id int) (*User, error) {
    var user *User  // nil pointer
    if notFound {
        return user, errors.New("not found")
    }
    return user, nil
}

err := doSomething()
if err != nil {
    log.Fatal(err)  // Program crashes here even though err "isn't nil"
}

// --- OR ---

var err error  // err is nil (zero value)
var myErr *MyError = nil  // *MyError pointer is nil

err = myErr  // Assign nil *MyError to error interface

// err != nil is TRUE — even though myErr is nil
if err != nil {
    fmt.Println("error:", err)  // Prints, even though the underlying value is nil
}

The code behaves as if a nil value is non-nil — one of Go’s most confusing edge cases.

Why This Happens

A Go interface value has two components internally:

  1. Type — the concrete type stored in the interface
  2. Value — a pointer to the concrete value

An interface is nil only when BOTH type and value are nil. If the type is set but the value is a nil pointer, the interface is not nil — it’s a non-nil interface holding a nil pointer.

var p *MyError = nil    // p is a nil *MyError pointer

var err error = p       // Assigns *MyError type + nil pointer to error interface
                        // Interface: {type: *MyError, value: nil}
                        // err != nil → TRUE — because type is set

var err2 error = nil    // Interface: {type: nil, value: nil}
                        // err2 != nil → FALSE — both are nil

This is consistent with Go’s interface specification, but surprises many developers because it breaks the intuition that “nil equals nil.”

It’s most common with:

  • Error returns — returning a typed nil pointer as an error interface
  • Testing helpers — returning a mock/stub that implements an interface but is nil
  • Optional parameters — interface-typed parameters where nil is meant to indicate “no value”

Fix 1: Return Plain nil for Error Returns

When a function returns an error interface, always return nil directly — never a typed nil pointer:

// WRONG — returns a non-nil interface holding a nil pointer
func validateUser(user *User) error {
    var err *ValidationError  // Typed nil pointer

    if user.Name == "" {
        err = &ValidationError{Field: "name", Message: "required"}
    }

    return err  // If err is nil *ValidationError, returns non-nil error interface
    // Caller: if err != nil → always true!
}

// CORRECT — return nil explicitly
func validateUser(user *User) error {
    if user.Name == "" {
        return &ValidationError{Field: "name", Message: "required"}
    }
    return nil  // Return untyped nil — interface is {nil, nil}
}
// WRONG — common pattern that creates the trap
func getConfig() (*Config, error) {
    var validationErr *ConfigError

    config, err := loadConfig()
    if err != nil {
        validationErr = &ConfigError{Cause: err}
    }

    if err := validateConfig(config); err != nil {
        validationErr = &ConfigError{Cause: err}
    }

    return config, validationErr  // If no error, validationErr is nil *ConfigError
    // Caller sees non-nil error even when everything succeeded
}

// CORRECT
func getConfig() (*Config, error) {
    config, err := loadConfig()
    if err != nil {
        return nil, &ConfigError{Cause: err}
    }

    if err := validateConfig(config); err != nil {
        return nil, &ConfigError{Cause: err}
    }

    return config, nil  // Plain nil — no trap
}

Fix 2: Detect Non-Nil Interfaces with Reflection

To check whether an interface’s underlying value is nil (when you can’t change the return type):

import "reflect"

func isNil(i interface{}) bool {
    if i == nil {
        return true  // Interface itself is nil
    }
    // Check if the underlying value is nil
    v := reflect.ValueOf(i)
    switch v.Kind() {
    case reflect.Ptr, reflect.Chan, reflect.Func, reflect.Interface,
         reflect.Map, reflect.Slice, reflect.UnsafePointer:
        return v.IsNil()
    default:
        return false
    }
}

// Usage
var myErr *MyError = nil
var err error = myErr

fmt.Println(err == nil)    // false — interface nil check
fmt.Println(isNil(err))    // true — underlying value is nil

Type assertion to check the concrete value:

var err error = (*MyError)(nil)  // Non-nil interface, nil pointer

if myErr, ok := err.(*MyError); ok && myErr == nil {
    fmt.Println("underlying *MyError is nil")
}

Use isNil() sparingly — if you need it frequently, the API has design issues. The real fix is to prevent the non-nil interface with nil pointer from being created in the first place.

Fix 3: Use Wrapper Types to Avoid the Trap

If you build error types, wrap them in a constructor that returns nil for the error interface when there’s no error:

type ValidationError struct {
    Fields []FieldError
}

func (e *ValidationError) Error() string {
    // ...
}

// Returns nil error when there are no field errors
func NewValidationError(fields []FieldError) error {
    if len(fields) == 0 {
        return nil  // Plain nil — not (*ValidationError)(nil)
    }
    return &ValidationError{Fields: fields}
}

// Usage — always returns a proper nil or non-nil error
func validate(user User) error {
    var fields []FieldError
    if user.Name == "" {
        fields = append(fields, FieldError{Field: "name", Message: "required"})
    }
    return NewValidationError(fields)  // nil if no errors
}

Fix 4: Understand the Error Interface Pattern

A practical example of where this bites developers in Go:

// Database error type
type DBError struct {
    Code    int
    Message string
}

func (e *DBError) Error() string {
    return fmt.Sprintf("DB error %d: %s", e.Code, e.Message)
}

// Repository function — BUGGY
func (r *UserRepo) FindByID(id int) (*User, error) {
    var dbErr *DBError

    row := r.db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    user := &User{}

    if err := row.Scan(&user.ID, &user.Name); err != nil {
        if err == sql.ErrNoRows {
            dbErr = &DBError{Code: 404, Message: "user not found"}
        } else {
            dbErr = &DBError{Code: 500, Message: err.Error()}
        }
    }

    return user, dbErr  // BUG: if no error, dbErr is (*DBError)(nil) — returns non-nil error
}

// Caller — fails unexpectedly
user, err := repo.FindByID(42)
if err != nil {
    log.Fatal("unexpected error:", err)  // Always fires due to interface trap
}
// FIXED — return nil explicitly on success
func (r *UserRepo) FindByID(id int) (*User, error) {
    row := r.db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    user := &User{}

    if err := row.Scan(&user.ID, &user.Name); err != nil {
        if err == sql.ErrNoRows {
            return nil, &DBError{Code: 404, Message: "user not found"}
        }
        return nil, &DBError{Code: 500, Message: err.Error()}
    }

    return user, nil  // Plain nil — no interface trap
}

Fix 5: Linting and Static Analysis

Go vet and staticcheck catch some nil interface issues:

# Go vet catches obvious cases
go vet ./...

# staticcheck — more thorough
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...

# golangci-lint — runs multiple linters including nilness analysis
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run --enable nilnil

nilnil linter — specifically catches functions that return a typed nil and an untyped nil together (indicating the interface trap may be possible):

// nilnil flags this pattern:
func getUser(id int) (*User, error) {
    // ...
    return nil, nil  // Returns typed nil — nilnil warns about this
}
// Because callers might do: if err != nil { ... } and miss the nil *User

Fix 6: Design APIs to Avoid the Trap

The best defense is API design that makes the trap impossible:

Use value types (not pointers) for small error types:

// Error as a value type — can't be nil
type NotFoundError struct {
    Resource string
    ID       int
}

func (e NotFoundError) Error() string {
    return fmt.Sprintf("%s with id %d not found", e.Resource, e.ID)
}

// Return value type — no nil pointer possible
func findUser(id int) (*User, error) {
    // ...
    return nil, NotFoundError{Resource: "user", ID: id}
    // Return non-pointer error — interface never holds a nil pointer
}

Use sentinel errors instead of custom types for simple cases:

var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")

// Return sentinel errors — no typed nil issue
func getUser(id int) (*User, error) {
    if id == 0 {
        return nil, ErrNotFound
    }
    // ...
}

// Caller uses errors.Is — works correctly with wrapping
if errors.Is(err, ErrNotFound) {
    // Handle not found
}

Return concrete types when possible:

// Instead of:
func process(handler Handler) error  // Handler is an interface

// Consider:
func process(handler *ConcreteHandler) error  // No interface, no nil trap

Still Not Working?

The method call panics on a nil interface — different from the nil interface check issue, this is a method called on a nil pointer through an interface:

var myErr *MyError = nil
myErr.Error()  // PANIC: nil pointer dereference

If MyError.Error() doesn’t dereference the pointer first, it panics. Fix by checking for nil in the method:

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

Using fmt.Println or string formatting on a nil interface — calling fmt.Println(err) on a non-nil interface with a nil pointer calls the Error() method on the nil pointer, potentially panicking. Use errors.Is and errors.As for error handling rather than string comparison.

The trap in concurrent code — if an error variable is set by one goroutine and checked by another, the interface read may see a partial state. Use proper synchronization when sharing interface values across goroutines.

For related Go issues, see Fix: Go Nil Pointer Dereference and Fix: Go Goroutine Leak.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles