Wrapping and Inspecting Errors

Updated

October 31, 2026

Wrapping and Inspecting Errors

Overview

Go 1.13 introduced error wrapping, allowing you to add context while preserving the original error for inspection.

Wrapping Errors

originalErr := errors.New("file not found")

// %w wraps the error
wrappedErr := fmt.Errorf("loading config: %w", originalErr)

// The error chain: wrappedErr -> originalErr

Unwrapping

err := fmt.Errorf("outer: %w",
    fmt.Errorf("middle: %w",
        errors.New("inner")))

inner := errors.Unwrap(err)  // middle: inner

errors.Is

Check if any error in the chain matches:

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

err := fmt.Errorf("user lookup: %w", ErrNotFound)

if errors.Is(err, ErrNotFound) {
    // Handle not found
}

errors.As

Extract specific error types:

type ValidationError struct {
    Field string
}

func (e *ValidationError) Error() string {
    return "validation: " + e.Field
}

err := fmt.Errorf("processing: %w", &ValidationError{Field: "email"})

var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Println("Invalid field:", valErr.Field)
}

Custom Wrappers

type WrappedError struct {
    Context string
    Err     error
}

func (e *WrappedError) Error() string {
    return fmt.Sprintf("%s: %v", e.Context, e.Err)
}

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

Best Practices

// Add context when crossing boundaries
func LoadUser(id int) (*User, error) {
    data, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("LoadUser(%d): %w", id, err)
    }
    return parse(data)
}

Summary

Function Purpose
fmt.Errorf("%w", err) Wrap error
errors.Unwrap(err) Get wrapped error
errors.Is(err, target) Check chain for match
errors.As(err, &target) Extract typed error

Worked example

Deep wrap chain with Is, As, and manual Unwrap walking.

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "errors"
    "fmt"
)

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

type HTTPError struct {
    Code int
    Err  error
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("http %d: %v", e.Code, e.Err)
}

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

func storeGet(key string) error {
    if key == "" {
        return ErrNotFound
    }
    return nil
}

func serviceGet(key string) error {
    if err := storeGet(key); err != nil {
        return fmt.Errorf("service.get(%q): %w", key, err)
    }
    return nil
}

func handlerGet(key string) error {
    if err := serviceGet(key); err != nil {
        return &HTTPError{Code: 404, Err: err}
    }
    return nil
}

func main() {
    err := handlerGet("")
    fmt.Println("chain:", err)
    fmt.Println("Is NotFound:", errors.Is(err, ErrNotFound))

    var he *HTTPError
    if errors.As(err, &he) {
        fmt.Println("As HTTPError code:", he.Code)
    }

    fmt.Print("unwrap walk:")
    for e := err; e != nil; e = errors.Unwrap(e) {
        fmt.Printf(" -> %v", e)
    }
    fmt.Println()
}

Expected output:

chain: http 404: service.get(""): not found
Is NotFound: true
As HTTPError code: 404
unwrap walk: -> http 404: service.get(""): not found -> service.get(""): not found -> not found

More examples

errors.Join + Is: each joined error stays inspectable.

package main

import (
    "errors"
    "fmt"
)

var (
    ErrName = errors.New("missing name")
    ErrAge  = errors.New("invalid age")
)

func validate(name string, age int) error {
    var errs error
    if name == "" {
        errs = errors.Join(errs, ErrName)
    }
    if age < 0 {
        errs = errors.Join(errs, ErrAge)
    }
    return errs
}

func main() {
    err := fmt.Errorf("signup: %w", validate("", -3))
    fmt.Println(err)
    fmt.Println("Is name:", errors.Is(err, ErrName))
    fmt.Println("Is age:", errors.Is(err, ErrAge))
    fmt.Println("Is notfound:", errors.Is(err, errors.New("not found")))
}

Expected output:

signup: missing name
invalid age
Is name: true
Is age: true
Is notfound: false

Runnable example

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "errors"
    "fmt"
)

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

type ValidationError struct {
    Field string
}

func (e *ValidationError) Error() string {
    return "validation: " + e.Field
}

type WrappedError struct {
    Context string
    Err     error
}

func (e *WrappedError) Error() string {
    return fmt.Sprintf("%s: %v", e.Context, e.Err)
}

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

func loadUser(id int) error {
    if id == 0 {
        return fmt.Errorf("user lookup: %w", ErrNotFound)
    }
    if id < 0 {
        return fmt.Errorf("processing: %w", &ValidationError{Field: "id"})
    }
    return nil
}

func main() {
    err := loadUser(0)
    fmt.Println("wrapped:", err)
    fmt.Println("Is NotFound:", errors.Is(err, ErrNotFound))
    fmt.Println("Unwrap once:", errors.Unwrap(err))

    err = loadUser(-1)
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Println("As ValidationError field:", ve.Field)
    }

    // Custom Unwrap still participates in Is/As chains.
    chain := &WrappedError{
        Context: "handler",
        Err:     fmt.Errorf("service: %w", ErrNotFound),
    }
    fmt.Println("custom wrap:", chain)
    fmt.Println("Is through custom wrap:", errors.Is(chain, ErrNotFound))
}

Expected output:

wrapped: user lookup: not found
Is NotFound: true
Unwrap once: not found
As ValidationError field: id
custom wrap: handler: service: not found
Is through custom wrap: true

What to notice: %w builds a chain; errors.Is walks that chain for sentinel equality, errors.As extracts a concrete type. Implementing Unwrap() error opts your type into the same machinery.

Try next: Nest three layers of fmt.Errorf("%w") and walk with a loop on errors.Unwrap; try errors.Join of two validation failures and inspect with errors.Is.