Wrapping and Inspecting Errors

Updated

September 13, 2026

Wrapping and Inspecting Errors

Wrapping adds context without erasing the original error. Inspecting walks that chain. The boring default is fmt.Errorf("verb: %w", err) on the way out, and errors.Is / errors.As on the way in. Do not parse Error() strings.

Mental model

  • %w (wrap) stores the inner error. %v / %s only copy the string; errors.Is will not see the inner value.
  • errors.Unwrap(err) returns the next inner error, or nil.
  • errors.Is(err, target) walks the chain (and joined errors) and reports whether any equals target (or implements an Is method).
  • errors.As(err, &dst) walks the chain and stores the first matching type in dst.
  • errors.Join(e1, e2, ...) combines independent failures into one value. Is / As see each of them.

Wrap when you add a layer (open ticket → charge card → print receipt). Join when several things failed at once (two tables both refused).

Worked examples

Case 1: %w keeps the sentinel reachable

The inner failure is ErrClosed. The outer message names the ticket. errors.Is still finds ErrClosed.

Save as wrap.go:

// wrap.go
package main

import (
    "errors"
    "fmt"
)

var ErrClosed = errors.New("desk closed")

func charge(open bool) error {
    if !open {
        return ErrClosed
    }
    return nil
}

func pay(ticket int, open bool) error {
    if err := charge(open); err != nil {
        return fmt.Errorf("pay ticket %d: %w", ticket, err)
    }
    return nil
}

func main() {
    err := pay(41, false)
    fmt.Println(err)
    fmt.Println("closed:", errors.Is(err, ErrClosed))
    fmt.Println("unwrap:", errors.Unwrap(err))
}

Run:

go run wrap.go

Output:

pay ticket 41: desk closed
closed: true
unwrap: desk closed

The printed string is for logs. The Is check is for control flow. Keep both.

Case 2: %v looks the same and breaks Is

Same program, %v instead of %w. The log line is identical. The sentinel is gone.

Save as nowrap.go:

// nowrap.go
package main

import (
    "errors"
    "fmt"
)

var ErrClosed = errors.New("desk closed")

func pay(ticket int) error {
    return fmt.Errorf("pay ticket %d: %v", ticket, ErrClosed)
}

func main() {
    err := pay(41)
    fmt.Println(err)
    fmt.Println("closed:", errors.Is(err, ErrClosed))
    fmt.Println("unwrap:", errors.Unwrap(err))
}

Run:

go run nowrap.go

Output:

pay ticket 41: desk closed
closed: false
unwrap: <nil>

This is the bug that shows up a month later: a handler that is supposed to treat “desk closed” as non-fatal never fires. Use %w when the inner error is a real error value.

Case 3: errors.As for a dedicated type

Wrapping must not hide fields. As finds TableError through the outer fmt.Errorf.

Save as as.go:

// as.go
package main

import (
    "errors"
    "fmt"
)

type TableError struct {
    Table int
    Msg   string
}

func (e TableError) Error() string {
    return fmt.Sprintf("table %d: %s", e.Table, e.Msg)
}

func openTable(n int) error {
    if n <= 0 {
        return TableError{Table: n, Msg: "number must be positive"}
    }
    return nil
}

func seat(n int) error {
    if err := openTable(n); err != nil {
        return fmt.Errorf("seat: %w", err)
    }
    return nil
}

func main() {
    err := seat(-2)
    var te TableError
    if errors.As(err, &te) {
        fmt.Printf("cannot seat table %d (%s)\n", te.Table, te.Msg)
        return
    }
    fmt.Println(err)
}

Run:

go run as.go

Output:

cannot seat table -2 (number must be positive)

As needs a pointer to the destination. Pointer vs value must match what was returned (TableError here, not *TableError). If you return &TableError{...}, pass **TableError or use var te *TableError; errors.As(err, &te).

Case 4: errors.Join for independent failures

Closing two tables: both can fail. Returning the first error hides the second. Join them.

Save as join.go:

// join.go
package main

import (
    "errors"
    "fmt"
)

var (
    ErrBusy  = errors.New("busy")
    ErrDirty = errors.New("dirty")
)

func closeTable(n int) error {
    switch n {
    case 3:
        return fmt.Errorf("table %d: %w", n, ErrBusy)
    case 7:
        return fmt.Errorf("table %d: %w", n, ErrDirty)
    default:
        fmt.Printf("closed table %d\n", n)
        return nil
    }
}

func closeAll(tables []int) error {
    var errs []error
    for _, n := range tables {
        if err := closeTable(n); err != nil {
            errs = append(errs, err)
        }
    }
    return errors.Join(errs...)
}

func main() {
    err := closeAll([]int{3, 4, 7})
    fmt.Println(err)
    fmt.Println("busy:", errors.Is(err, ErrBusy))
    fmt.Println("dirty:", errors.Is(err, ErrDirty))
}

Run:

go run join.go

Output:

closed table 4
table 3: busy
table 7: dirty
busy: true
dirty: true

errors.Join skips nils. Joining a single error returns that error. Joining nothing returns nil. Is sees through the join.

The trap

String matching on err.Error(). A wrap changes the string. Your strings.Contains(err.Error(), "closed") misses pay ticket 41: desk closed if someone rewords the inner message — or matches the wrong error that happens to contain the word.

Save as by_string.go:

// by_string.go
package main

import (
    "errors"
    "fmt"
    "strings"
)

var ErrClosed = errors.New("desk closed")

func main() {
    err := fmt.Errorf("pay ticket %d: %w", 41, ErrClosed)
    fmt.Println("contains closed:", strings.Contains(err.Error(), "closed"))

    other := errors.New("enclosure door closed")
    fmt.Println("false friend:", strings.Contains(other.Error(), "closed"))
    fmt.Println("Is ErrClosed:", errors.Is(other, ErrClosed))
}

Run:

go run by_string.go

Output:

contains closed: true
false friend: true
Is ErrClosed: false

errors.Is is the comparison. Strings are for logs.

The boring rule

  • Wrap with %w when the caller might inspect the inner error.
  • fmt.Errorf("op: %w", err) — operation first, inner error last.
  • errors.Is for sentinels. errors.As for types with fields.
  • errors.Join when failures are independent. Do not Join a single causal chain; wrap that.
  • Do not compare err.Error() in if statements.
  • Pointer vs value: return one, inspect the same one.

Try this

  1. In wrap.go, change %w to %v. Confirm Is becomes false. Change it back.
  2. Return *TableError from openTable in as.go. Fix the As destination so the program still prints cannot seat table -2.
  3. In join.go, close only table 4. Print err == nil (it should be true).