Skip to content

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

FixDevs · (Updated: )

Part of:  Go, Rust & Systems Errors

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 behavior is consistent with Go’s interface specification, but it surprises many developers because it breaks the intuition that “nil equals nil.” The root cause is Go’s interface representation: every interface value is a “fat pointer” consisting of two machine words — one for the type descriptor and one for the data pointer. When you assign a typed nil pointer to an interface, the type word gets set (to *MyError), even though the data word is nil. The == nil check on an interface tests whether both words are zero. Since the type word is not zero, the interface is not nil.

This is 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”

The trap is especially dangerous because it only triggers at runtime and only when a specific code path returns the typed nil. In testing, if the “no error” path is exercised less frequently than the “has error” path, the bug can ship to production unnoticed.

Diagnostic Timeline

When you suspect the typed-nil interface trap but aren’t sure where it’s happening, follow this process.

Minute 0 — Reproduce the panic or unexpected behavior. Run the failing test or trigger the code path. Note the exact error: either runtime error: nil pointer dereference (if the nil-wrapped value is dereferenced) or unexpected control flow (if the != nil check diverts to the wrong branch).

Minute 1 — Print the interface’s type and value separately. At the point where err != nil behaves unexpectedly, add:

fmt.Printf("err == nil: %v\n", err == nil)
fmt.Printf("Type: %v\n", reflect.TypeOf(err))
fmt.Printf("Value: %v\n", reflect.ValueOf(err))

If the output shows err == nil: false but Type: *main.MyError and Value: <nil>, you have confirmed the typed-nil trap. The interface holds a type descriptor (*MyError) with a nil data pointer.

Minute 2 — Trace the return path. Find the function that returns the error interface value. Look for this pattern:

func doWork() error {
    var customErr *CustomError  // Typed nil pointer
    // ... logic that might set customErr ...
    return customErr  // Returns (*CustomError)(nil), NOT untyped nil
}

The fix is to return nil directly instead of the typed variable. But first, confirm this is the offending function by checking the call chain.

Minute 3 — Check with reflect.ValueOf().IsNil(). If you can’t easily modify the returning function, add a diagnostic check at the call site:

err := doWork()
if err != nil {
    v := reflect.ValueOf(err)
    if v.Kind() == reflect.Ptr && v.IsNil() {
        fmt.Println("TRAP: non-nil interface with nil pointer")
    }
}

This confirms the trap without modifying the function signature.

Minute 4 — Understand the interface fat pointer. Visualize the two-word structure:

Untyped nil:     | type: nil      | value: nil     | → == nil is TRUE
Typed nil:       | type: *MyError | value: nil     | → == nil is FALSE
Normal value:    | type: *MyError | value: 0xc000  | → == nil is FALSE

The == nil comparison checks both words simultaneously. Only when both are zero does the comparison return true. This is why assigning a typed nil pointer to an interface changes the nil check result — it sets the type word.

Minute 5 — Run static analysis. Use golangci-lint with the nilnil linter enabled to catch this pattern project-wide:

golangci-lint run --enable nilnil ./...

This flags functions that return a typed nil alongside an untyped nil, which is the most common source of the trap.

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.

The trap with errors.As and errors.Iserrors.As(err, &target) successfully assigns when the interface holds a typed nil. This means errors.As returns true, but the target variable is nil:

var err error = (*MyError)(nil)

var target *MyError
if errors.As(err, &target) {
    // This block runs — errors.As matched the type
    fmt.Println(target == nil)  // true — target is nil
    target.SomeMethod()         // PANIC
}

Always nil-check the target after errors.As if you intend to call methods on it.

Interface comparison across packages — if two packages define the same-looking interface type, they are distinct types in Go. An interface value from package A assigned to an interface variable of package B’s type can trigger unexpected nil behavior if the types don’t match. Use reflect.TypeOf to verify the concrete type stored in the interface when debugging cross-package issues.

Map and slice values in interfaces — maps and slices can also be nil, and assigning a nil map or nil slice to an interface creates the same trap. var m map[string]int = nil; var i interface{} = m; i != nil evaluates to true. The same isNil() helper from Fix 2 handles these cases.

For related Go issues, see Fix: Go Nil Pointer Dereference, Fix: Go Goroutine Leak, Fix: Go Channel Deadlock, and Fix: Go Context Deadline Exceeded.

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