Skip to content

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

FixDevs · (Updated: )

Part of:  Go, Rust & Systems Errors

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.

The production consequence is subtle and dangerous: code compiles, tests may pass, but error-handling branches execute against the wrong conditions in production. A handler that should return HTTP 404 returns HTTP 500 because errors.Is(err, ErrNotFound) silently returns false after a wrap with %v. A retry loop that should give up on context.DeadlineExceeded keeps retrying because the wrapped error no longer matches. A migration that should skip duplicate-key errors aborts on the first one. None of these failures throw a runtime panic — they just take the wrong branch, and the wrong branch often looks like a legitimate “something went wrong” code path.

The blast radius is per-error-code. If only the “user not found” check is broken, only that one endpoint behaves oddly. If a foundational sentinel like sql.ErrNoRows is wrapped wrong throughout your data layer, dozens of business operations silently misclassify their errors. The cumulative effect is a system that does the wrong thing under specific conditions you can’t reliably reproduce in staging — exactly the kind of bug that erodes trust in a service over weeks of intermittent reports.

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)
    }
}

Fix 8: Detect Wrong-Branch Execution in Production

The hardest part of debugging an errors.Is regression is that it doesn’t crash — it just routes the request down the wrong code path. The “internal server error” branch fires when “not found” should have. To catch this, instrument your error-classification points to emit metrics for each branch taken.

Emit a counter per error classification:

import (
    "errors"
    "github.com/prometheus/client_golang/prometheus"
)

var errorBranchCounter = prometheus.NewCounterVec(
    prometheus.CounterOpts{
        Name: "http_error_branch_total",
        Help: "Count of error branches taken in HTTP handlers",
    },
    []string{"handler", "branch"},
)

func handleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := userService.Get(parseID(r))
    switch {
    case errors.Is(err, ErrNotFound):
        errorBranchCounter.WithLabelValues("get_user", "not_found").Inc()
        http.Error(w, "not found", http.StatusNotFound)
        return
    case errors.Is(err, ErrPermission):
        errorBranchCounter.WithLabelValues("get_user", "forbidden").Inc()
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    case err != nil:
        errorBranchCounter.WithLabelValues("get_user", "internal").Inc()
        slog.Error("get_user failed", "error", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    // ...
}

If you suddenly see the internal branch spike while not_found drops, you have a wrapping regression: errors that used to match ErrNotFound now fall through to the catch-all. The metric makes a silent classification bug visible.

Alert on unexpected-branch ratios:

Set a baseline ratio between branches. For a user-lookup endpoint, you might expect 5% not_found, 1% forbidden, and less than 0.1% internal. Alert when the internal ratio exceeds 1% over a 10-minute window. This catches both new bugs introduced by a deploy and dependency upgrades that changed error types (e.g., a database driver that now returns a custom error wrapping sql.ErrNoRows through %v).

Real-world scenario: A team upgraded their PostgreSQL driver. The new driver wrapped sql.ErrNoRows using %v instead of %w somewhere in its internal stack. Every existing errors.Is(err, sql.ErrNoRows) check started returning false. Endpoints that should return 404 started returning 500. The bug was invisible in unit tests (which mocked the database) and only surfaced once real traffic flowed against the new driver. A branch-execution counter would have caught the spike in 500s within minutes.

Static analysis to prevent regressions:

Tools like errorlint (go install github.com/polyfloyd/go-errorlint@latest) flag == comparisons against errors, uses of %v/%s instead of %w, and type assertions that should be errors.As. Add it to your CI pipeline:

# .golangci.yml
linters:
  enable:
    - errorlint
    - errcheck

This makes the wrong patterns fail at build time, before they reach production.

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.

Comparing wrapped errors across package boundaries — if package A defines ErrNotFound and package B defines its own ErrNotFound, errors.Is between them returns false even if the messages match. They are different pointers. Choose one canonical sentinel per error condition and import it everywhere it’s checked.

errors.Is and pointer-equality on dynamic errorserrors.New("foo") returns a different pointer each call. Two calls to errors.New("not found") produce two distinct errors that don’t match via errors.Is. Always define sentinels as package-level var so there’s exactly one canonical instance.

errors.As with interface types — if you pass a target of interface type (e.g., var target net.Error), errors.As requires &target to be a pointer to that interface. Some types (like *net.OpError) implement net.Error and assigning to an interface target works, but the syntax can be confusing. When in doubt, target the concrete pointer type, not the interface.

For related Go issues, see Fix: Go Goroutine Leak, Fix: Go Interface Nil Panic, Fix: Go Context Deadline Exceeded, and Fix: Go Panic Recover Best Practices.

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