Skip to content

Fix: Go Error Handling Not Working — errors.Is, errors.As, and Wrapping

FixDevs ·

Quick Answer

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.

The Problem

errors.Is returns false even though the error matches:

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

func getUser(id int) error {
    return fmt.Errorf("user service: %v", ErrNotFound)  // Wrapping with %v
}

err := getUser(42)
fmt.Println(errors.Is(err, ErrNotFound))  // false — wrapping with %v loses the chain

Or errors.As fails to extract the custom error type:

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}

err := validate(input)
var ve ValidationError    // Not a pointer — wrong type for errors.As
if errors.As(err, &ve) {
    fmt.Println(ve.Field)  // Never reached — errors.As returns false
}

Or error comparison with == breaks after wrapping:

if err == sql.ErrNoRows {   // false after wrapping
    // Handle not found
}

Why This Happens

Go 1.13 introduced error wrapping via fmt.Errorf with the %w verb. The errors.Is and errors.As functions unwrap errors recursively — but only if the error was wrapped with %w, not %v or %s.

Key distinctions:

  • %w wraps the errorerrors.Is and errors.As can unwrap through it. The wrapped error is accessible via errors.Unwrap().
  • %v and %s format the error as a string — the original error is lost. errors.Is and errors.As can’t unwrap through a stringified error.
  • == comparison only works for identical pointers — sentinel errors (var ErrNotFound = errors.New(...)) are pointer values. A wrapped error is a different pointer, so == fails.
  • errors.As requires a pointer to the target type — if ValidationError implements error with a pointer receiver (*ValidationError), errors.As needs *ValidationError as the target.

Fix 1: Use %w to Wrap Errors

Replace %v and %s with %w when adding context to errors that need to be inspectable:

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")

// WRONG — %v serializes the error as string, loses the chain
func getUserWrong(id int) error {
    return fmt.Errorf("user service: %v", ErrNotFound)
}

// CORRECT — %w wraps the error, preserves the chain
func getUser(id int) error {
    return fmt.Errorf("user service: %w", ErrNotFound)
}

// Works correctly:
err := getUser(42)
fmt.Println(errors.Is(err, ErrNotFound))   // true — %w preserves the chain
fmt.Println(err.Error())                   // "user service: not found"

// Multi-level wrapping — errors.Is unwraps recursively
func getProfile(userID int) error {
    if err := getUser(userID); err != nil {
        return fmt.Errorf("profile service: %w", err)
    }
    return nil
}

err = getProfile(42)
fmt.Println(errors.Is(err, ErrNotFound))   // true — unwraps through both layers

When to use %w vs %v:

  • Use %w when the caller needs to inspect the error type or compare against sentinel values.
  • Use %v or %s when you’re logging and don’t need the caller to inspect the error further — or when you intentionally want to hide implementation details.
// Service layer — wrap to preserve error chain
func (s *UserService) Get(id int) (*User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        return nil, fmt.Errorf("UserService.Get(%d): %w", id, err)
    }
    return user, nil
}

// HTTP handler — only log, don't need the chain to propagate
func handleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := userService.Get(id)
    if err != nil {
        log.Printf("failed to get user: %v", err)   // %v is fine here — just logging
        http.Error(w, "internal server error", 500)
        return
    }
    json.NewEncoder(w).Encode(user)
}

Fix 2: Use errors.Is for Sentinel Error Comparison

Replace == comparisons with errors.Is for wrapped errors:

import (
    "database/sql"
    "errors"
)

// WRONG — == breaks after wrapping
func getUserWrong(db *sql.DB, id int) (*User, error) {
    row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    var user User
    if err := row.Scan(&user.ID, &user.Name); err != nil {
        if err == sql.ErrNoRows {   // Only works if err is exactly sql.ErrNoRows
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("scan: %v", err)
    }
    return &user, nil
}

// CORRECT — errors.Is unwraps through the chain
func getUser(db *sql.DB, id int) (*User, error) {
    row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    var user User
    if err := row.Scan(&user.ID, &user.Name); err != nil {
        if errors.Is(err, sql.ErrNoRows) {   // Works even if err is wrapped
            return nil, fmt.Errorf("user %d: %w", id, ErrUserNotFound)
        }
        return nil, fmt.Errorf("query user %d: %w", id, err)
    }
    return &user, nil
}

// Caller uses errors.Is to check the specific error condition
user, err := getUser(db, 42)
if errors.Is(err, ErrUserNotFound) {
    // Handle "not found" specifically
    http.Error(w, "user not found", http.StatusNotFound)
    return
}
if err != nil {
    // Handle other errors
    http.Error(w, "internal error", http.StatusInternalServerError)
    return
}

Custom Is method for complex sentinel logic:

type StatusError struct {
    Code    int
    Message string
}

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

// Custom Is — match by status code regardless of message
func (e *StatusError) Is(target error) bool {
    t, ok := target.(*StatusError)
    if !ok {
        return false
    }
    return e.Code == t.Code  // Match if codes are equal
}

var ErrNotFound = &StatusError{Code: 404}
var ErrForbidden = &StatusError{Code: 403}

err := &StatusError{Code: 404, Message: "user not found"}
fmt.Println(errors.Is(err, ErrNotFound))   // true — same code, custom Is method

Fix 3: Use errors.As to Extract Error Types

errors.As finds the first error in the chain that matches the target type:

// Custom error type with pointer receiver
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

// Function that returns a wrapped ValidationError
func validate(email string) error {
    if !strings.Contains(email, "@") {
        return fmt.Errorf("validate: %w", &ValidationError{
            Field:   "email",
            Message: "must contain @",
        })
    }
    return nil
}

err := validate("notanemail")

// WRONG — value type, not pointer type
var ve ValidationError
if errors.As(err, &ve) {   // false — ValidationError implements error as *ValidationError
    fmt.Println(ve.Field)
}

// CORRECT — pointer type
var ve *ValidationError
if errors.As(err, &ve) {   // true — errors.As finds *ValidationError in the chain
    fmt.Println(ve.Field)   // "email"
    fmt.Println(ve.Message) // "must contain @"
}

Extract multiple error types from a chain:

// Check for specific types at different layers
func handleError(err error) {
    var netErr *net.OpError
    var dnsErr *net.DNSError
    var ve *ValidationError

    switch {
    case errors.As(err, &ve):
        fmt.Printf("Validation failed on field %s: %s\n", ve.Field, ve.Message)
    case errors.As(err, &dnsErr):
        fmt.Printf("DNS error: %s\n", dnsErr.Name)
    case errors.As(err, &netErr):
        fmt.Printf("Network error: %s\n", netErr.Op)
    default:
        fmt.Printf("Unknown error: %v\n", err)
    }
}

Fix 4: Implement the Unwrap Method for Custom Error Types

If you build a custom error type that wraps another error, implement Unwrap() so errors.Is and errors.As can traverse the chain:

type AppError struct {
    Code    string
    Message string
    Err     error  // The wrapped error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

// Unwrap — enables errors.Is and errors.As to traverse through AppError
func (e *AppError) Unwrap() error {
    return e.Err
}

// Usage
var ErrDBConnection = errors.New("database connection failed")

func connectDB() error {
    return &AppError{
        Code:    "DB_001",
        Message: "cannot connect",
        Err:     ErrDBConnection,
    }
}

err := connectDB()
fmt.Println(errors.Is(err, ErrDBConnection))  // true — Unwrap() traverses AppError

Wrapping multiple errors (Go 1.20+):

// Go 1.20 — join multiple errors
err1 := errors.New("database error")
err2 := errors.New("cache error")

combined := errors.Join(err1, err2)
fmt.Println(errors.Is(combined, err1))  // true
fmt.Println(errors.Is(combined, err2))  // true
fmt.Println(combined.Error())           // "database error\ncache error"

// fmt.Errorf with multiple %w (Go 1.20+)
combined2 := fmt.Errorf("failed: %w; also: %w", err1, err2)
fmt.Println(errors.Is(combined2, err1))  // true
fmt.Println(errors.Is(combined2, err2))  // true

Fix 5: Add Stack Traces to Errors

Go’s standard library doesn’t capture stack traces automatically. Use github.com/pkg/errors or golang.org/x/xerrors for stack traces:

import "github.com/pkg/errors"

// Wrap with stack trace (only at the origin — not at every wrapping level)
func readConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        return errors.Wrap(err, "read config")   // Captures stack trace here
    }
    return nil
}

// Add context without a new stack frame
func loadApp() error {
    if err := readConfig("config.yaml"); err != nil {
        return errors.WithMessage(err, "load app")  // Adds context, no new trace
    }
    return nil
}

// Print with stack trace
err := loadApp()
if err != nil {
    fmt.Printf("%+v\n", err)  // %+v prints the full stack trace
}

With slog or zerolog — log the error with context:

import "log/slog"

if err != nil {
    slog.Error("operation failed",
        "error", err,
        "user_id", userID,
        "operation", "getUser",
    )
}

Fix 6: Sentinel Error Pattern

Define sentinel errors as package-level variables so callers can compare against them:

// errors.go — package-level sentinel errors
package user

import "errors"

// Use var + errors.New, not const — errors.New returns a pointer
var (
    ErrNotFound    = errors.New("user not found")
    ErrInvalidID   = errors.New("invalid user ID")
    ErrPermission  = errors.New("insufficient permissions")
    ErrEmailTaken  = errors.New("email address already in use")
)

// Wrap sentinels with context
func GetByEmail(email string) (*User, error) {
    // ...
    if notFound {
        return nil, fmt.Errorf("GetByEmail(%q): %w", email, ErrNotFound)
    }
    return user, nil
}
// Caller — use errors.Is for comparison
user, err := user.GetByEmail("[email protected]")
switch {
case errors.Is(err, user.ErrNotFound):
    http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, user.ErrPermission):
    http.Error(w, "forbidden", http.StatusForbidden)
case err != nil:
    slog.Error("unexpected error", "error", err)
    http.Error(w, "internal server error", http.StatusInternalServerError)
}

Fix 7: Test Error Handling

Verify that error wrapping and comparison work correctly in tests:

package user_test

import (
    "errors"
    "testing"

    "myapp/user"
)

func TestGetByEmail_NotFound(t *testing.T) {
    svc := newTestService(t)  // Set up with empty database

    _, err := svc.GetByEmail("[email protected]")

    // Verify the correct sentinel error is returned (even if wrapped)
    if !errors.Is(err, user.ErrNotFound) {
        t.Errorf("expected ErrNotFound, got: %v", err)
    }
}

func TestValidate_Returns_ValidationError(t *testing.T) {
    err := validate("")

    var ve *ValidationError
    if !errors.As(err, &ve) {
        t.Fatalf("expected ValidationError, got: %T: %v", err, err)
    }

    if ve.Field != "email" {
        t.Errorf("expected Field 'email', got %q", ve.Field)
    }
}

Still Not Working?

Third-party errors not wrapping — some libraries return errors that don’t support Unwrap(). If errors.Is(err, targetErr) fails with a library error, check if the library provides its own comparison function (e.g., grpc/status.Code(err) instead of errors.Is).

panic vs error — Go’s convention is to use errors for expected failure conditions and panic only for truly unexpected states (programming errors, unrecoverable conditions). Don’t recover from panics to return errors unless you’re in a framework boundary (like an HTTP handler’s middleware).

Ignoring errors_ = someFunc() silently discards errors. Use //nolint:errcheck with justification if truly intentional, and run errcheck or staticcheck in CI to catch discarded errors.

Error messages should not be capitalized or end with punctuation — Go convention: error strings are lowercase and don’t end with . or !. They’re often used in larger error messages: fmt.Errorf("open file: %w", err) reads naturally as a sentence fragment.

For related Go issues, see Fix: Go Goroutine Leak and Fix: Go Interface Nil Panic.

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