Introduction

Go 1.13 introduced errors.Is and errors.As for error inspection. A subtle bug occurs when errors.As returns true but the target variable receives a nil pointer, or when a custom error type's Unwrap() method returns nil, causing nil pointer dereference when the caller tries to access the unwrapped error's fields.

Symptoms

  • panic: runtime error: invalid memory address or nil pointer dereference
  • Stack trace points to line accessing fields after errors.As
  • errors.Is(err, target) returns true but err is actually nil
  • Custom error types with nil embedded errors

```go type APIError struct { StatusCode int Message string Err error }

func (e *APIError) Unwrap() error { return e.Err }

// In caller: var apiErr *APIError if errors.As(err, &apiErr) { fmt.Println(apiErr.StatusCode) // PANIC if apiErr is nil! } ```

Common Causes

  • Custom error types with nil Unwrap() return
  • errors.As with pointer-to-pointer that resolves to nil
  • Error chain where intermediate errors are nil
  • Third-party libraries returning non-standard error types
  • Wrapping nil errors: fmt.Errorf("context: %w", nil) produces non-nil error

Step-by-Step Fix

  1. 1.Always check for nil after errors.As:
  2. 2.```go
  3. 3.var apiErr *APIError
  4. 4.if errors.As(err, &apiErr) && apiErr != nil {
  5. 5.// Safe to access apiErr fields
  6. 6.log.Printf("API error %d: %s", apiErr.StatusCode, apiErr.Message)
  7. 7.}
  8. 8.`
  9. 9.Fix custom error Unwrap to not return nil:
  10. 10.```go
  11. 11.// WRONG - Unwrap can return nil
  12. 12.type MyError struct {
  13. 13.cause error
  14. 14.}

func (e *MyError) Unwrap() error { return e.cause // May be nil! }

// CORRECT - only implement Unwrap if cause is non-nil func (e *MyError) Unwrap() error { if e.cause == nil { return nil // errors.Is/As handle this correctly } return e.cause }

// Better: use fmt.Errorf for wrapping func (e *MyError) Unwrap() error { return e.cause }

// And never create with nil cause: func NewMyError(msg string) error { return &MyError{Message: msg} // cause defaults to nil, Unwrap handles it } ```

  1. 1.Avoid wrapping nil errors:
  2. 2.```go
  3. 3.// WRONG - creates a non-nil error wrapping nil
  4. 4.func doSomething() error {
  5. 5.err := mightReturnNil()
  6. 6.if err != nil {
  7. 7.return fmt.Errorf("failed: %w", err)
  8. 8.}
  9. 9.return nil // This is fine
  10. 10.}

// If err is nil and you return fmt.Errorf("failed: %w", nil), // the returned error is non-nil but unwraps to nil ```

  1. 1.Use safe unwrap helper:
  2. 2.```go
  3. 3.func UnwrapAs[T error](err error) (T, bool) {
  4. 4.var target T
  5. 5.if errors.As(err, &target) {
  6. 6.var zero T
  7. 7.if target != zero { // Type-safe nil check
  8. 8.return target, true
  9. 9.}
  10. 10.}
  11. 11.return zero, false
  12. 12.}

// Usage if apiErr, ok := UnwrapAs[*APIError](err); ok { fmt.Println(apiErr.StatusCode) // Safe } ```

Prevention

  • Always add nil check after errors.As
  • Never return fmt.Errorf("msg: %w", nil) - check before wrapping
  • Test error chains with errors.Is(err, nil) - should always return false
  • Use go vet to catch common nil pointer issues
  • Write table-driven tests that include nil error cases
  • Consider using github.com/pkg/errors for stack traces with proper nil handling