Wrapping and Inspecting Errors

Updated

July 30, 2026

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.