Fix: Go Interface Nil Panic — Non-Nil Interface Holding a Nil Pointer
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:
- Type — the concrete type stored in the interface
- 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 nilThis 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
errorinterface - 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 FALSEThe == 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 nilType 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 nilnilnilnil 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 *UserFix 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 trapStill 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 dereferenceIf 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.Is — errors.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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Go Test Not Working — Tests Not Running, Failing Unexpectedly, or Coverage Not Collected
How to fix Go testing issues — test function naming, table-driven tests, t.Run subtests, httptest, testify assertions, and common go test flag errors.
Fix: Go Generics Type Constraint Error — Does Not Implement or Cannot Use as Type
How to fix Go generics errors — type constraints, interface vs constraint, comparable, union types, type inference failures, and common generic function pitfalls.
Fix: Go Deadlock — all goroutines are asleep, deadlock!
How to fix Go channel deadlocks — unbuffered vs buffered channels, missing goroutines, select statements, closing channels, sync primitives, and detecting deadlocks with go race detector.
Fix: Go Error Handling Not Working — errors.Is, errors.As, and Wrapping
How to fix Go error handling — errors.Is vs ==, errors.As for type extraction, fmt.Errorf %w for wrapping, sentinel errors, custom error types, and stack traces.