Fix: Go Interface Nil Panic — Non-Nil Interface Holding a Nil Pointer
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 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
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”
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.
For related Go issues, see Fix: Go Nil Pointer Dereference and Fix: Go Goroutine Leak.
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.