Skip to content

%w wraps and errors.Is sees through it

Level: 201 · anyone whose err == ErrNotFound stopped matching the day a layer of context was added

One line: fmt.Errorf("…: %w", err) keeps err reachable underneath the new message and %v does not; errors.Is walks that chain and finds a sentinel at any depth, == compares only the outermost value, errors.Unwrap takes one layer off, and a type with an Is(error) bool method decides for itself what it matches.

findOrder returns the sentinel ErrNotFound wrapped with the id it was asked for. main wraps that once more, once with %w and once with %v, compares the two, and walks the %w chain down with errors.Unwrap. Then it does the same with two sentinels from the standard library — io.EOF from a read that found nothing, and fs.ErrNotExist from opening a file that is not there — and ends with a type whose Is method makes every 5xx status match ErrRetryable.

Verified output of wrap_with_w_unwrap_with_is_go.go — regenerated by tools/run_examples.py, never hand-typed.

wrapped   : fulfil order 7: find order 7: order not found
formatted : fulfil order 7: find order 7: order not found
same text : true

errors.Is(wrapped, ErrNotFound)   = true
errors.Is(formatted, ErrNotFound) = false
wrapped == ErrNotFound            = false

Unwrap(wrapped)         : find order 7: order not found
Unwrap(Unwrap(wrapped)) : order not found
  ... == ErrNotFound    : true
Unwrap(ErrNotFound)     : <nil>
Unwrap(formatted)       : <nil>

errors.Is(err, io.EOF)             = true - read order header: EOF
os.Open: err == fs.ErrNotExist     = false
os.Open: errors.Is(fs.ErrNotExist) = true

503: errors.Is(err, ErrRetryable)   = true
404: errors.Is(err, ErrRetryable)   = false
503: errors.Is(err, HTTPError{503}) = true

Reading the output

The two errors print the same text. same text : true. The documentation of fmt.Errorf ↗ says %w is otherwise a synonym for %v: the message is built the same way. The difference is in the value returned, not in its string — with %w, the returned error has an Unwrap method that returns the operand, so the operand stays reachable.

errors.Is finds the sentinel two layers down; == does not. errors.Is(wrapped, ErrNotFound) = true while wrapped == ErrNotFound = false. == on two interface values compares their dynamic types and values (spec ↗), and the outer value is a *fmt.wrapError, never the sentinel. errors.Is instead examines the tree: the error itself, then what Unwrap returns, and so on (package errors ↗). For formatted the tree is one value deep, so errors.Is is false — the cause became text.

Unwrap takes exactly one layer off. fulfil order 7 → find order 7 → the sentinel → <nil>. errors.Unwrap ↗ calls the Unwrap() error method if the value has one and returns nil otherwise, so it ends on the sentinel, and returns nil at once for formatted. It does not call an Unwrap() []error — errors.Join holds many errors in one shows that.

The standard library's sentinels are found the same way. io.EOF was wrapped by the read and errors.Is still finds it. The error from os.Open is a *fs.PathError holding a syscall.Errno; err == fs.ErrNotExist is false, and errors.Is is true because syscall.Errno has an Is method ↗ that maps ENOENT to fs.ErrNotExist (source at go1.25.5 ↗). The program prints only the two booleans: the message names the path and uses the operating system's wording.

A type's Is method extends matching beyond equality. HTTPError{503} is not equal to ErrRetryable — different types — yet errors.Is answers true, because the docs make a match either equality or an Is(target) bool that returns true (package errors ↗). HTTPError{404} answers false through the same method. Equality still works too: errors.Is(gateway, HTTPError{503}) is true because the struct is comparable and equal.

wrap_with_w_unwrap_with_is_go.go

wrap_with_w_unwrap_with_is_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Wrapping with %w keeps the cause and %v drops it: the two errors print the
// same text, but only the wrapped one answers errors.Is. Is walks the chain
// of Unwrap results and finds the sentinel wherever it sits; == compares only
// the outermost value. A type with an Is method decides what it matches.
//
//  go build wrap_with_w_unwrap_with_is_go.go && ./wrap_with_w_unwrap_with_is_go
package main

import (
    "errors"
    "fmt"
    "io"
    "io/fs"
    "os"
    "strings"
)

// ErrNotFound is a sentinel: one exported value that callers compare against.
var ErrNotFound = errors.New("order not found")

// findOrder fails for every id, says which id, and keeps the sentinel underneath.
func findOrder(id int) error {
    return fmt.Errorf("find order %d: %w", id, ErrNotFound)
}

// ErrRetryable is what a caller asks about; HTTPError answers through its Is method.
var ErrRetryable = errors.New("retryable")

// HTTPError is a failed call to another service.
type HTTPError struct{ Status int }

func (e HTTPError) Error() string { return fmt.Sprintf("http status %d", e.Status) }

// Is makes every 5xx HTTPError match ErrRetryable without being equal to it.
func (e HTTPError) Is(target error) bool { return target == ErrRetryable && e.Status >= 500 }

func main() {
    wrapped := fmt.Errorf("fulfil order 7: %w", findOrder(7))
    formatted := fmt.Errorf("fulfil order 7: %v", findOrder(7))
    fmt.Println("wrapped   :", wrapped)
    fmt.Println("formatted :", formatted)
    fmt.Println("same text :", wrapped.Error() == formatted.Error())
    fmt.Println()

    fmt.Println("errors.Is(wrapped, ErrNotFound)   =", errors.Is(wrapped, ErrNotFound))
    fmt.Println("errors.Is(formatted, ErrNotFound) =", errors.Is(formatted, ErrNotFound))
    fmt.Println("wrapped == ErrNotFound            =", wrapped == ErrNotFound)
    fmt.Println()

    // Unwrap takes one layer off: fulfil -> find -> the sentinel -> nil.
    fmt.Println("Unwrap(wrapped)         :", errors.Unwrap(wrapped))
    fmt.Println("Unwrap(Unwrap(wrapped)) :", errors.Unwrap(errors.Unwrap(wrapped)))
    fmt.Println("  ... == ErrNotFound    :", errors.Unwrap(errors.Unwrap(wrapped)) == ErrNotFound)
    fmt.Println("Unwrap(ErrNotFound)     :", errors.Unwrap(ErrNotFound))
    fmt.Println("Unwrap(formatted)       :", errors.Unwrap(formatted))
    fmt.Println()

    // Two sentinels from the standard library, wrapped by whoever returned them.
    _, err := io.ReadFull(strings.NewReader(""), make([]byte, 4))
    err = fmt.Errorf("read order header: %w", err)
    fmt.Println("errors.Is(err, io.EOF)             =", errors.Is(err, io.EOF), "-", err)
    _, err = os.Open("orders/does-not-exist.csv")
    fmt.Println("os.Open: err == fs.ErrNotExist     =", err == fs.ErrNotExist)
    fmt.Println("os.Open: errors.Is(fs.ErrNotExist) =", errors.Is(err, fs.ErrNotExist))
    fmt.Println()

    gateway := fmt.Errorf("call pricing service: %w", HTTPError{503})
    missing := fmt.Errorf("call pricing service: %w", HTTPError{404})
    fmt.Println("503: errors.Is(err, ErrRetryable)   =", errors.Is(gateway, ErrRetryable))
    fmt.Println("404: errors.Is(err, ErrRetryable)   =", errors.Is(missing, ErrRetryable))
    fmt.Println("503: errors.Is(err, HTTPError{503}) =", errors.Is(gateway, HTTPError{503}))
}

What to do

  • Wrap with %w when the caller may need to act on the cause, and put what this layer knows — which order, which file, which query — in front of it. The chain reads left to right as the call stack unwinds.
  • Test with errors.Is(err, ErrX), never err == ErrX, for any error that crossed a function boundary: since Go 1.13 any of them may have been wrapped on the way.
  • Export sentinels as var ErrX = errors.New("…") and document which functions return them. A sentinel is part of the package's API; the standard library's io.EOF and fs.ErrNotExist are the model.
  • Write an Is method only when equality is the wrong test — a class of statuses, a family of codes — and keep it shallow: the docs say an Is method should not call Unwrap on either side.
  • Use %v on purpose, when the cause must not become part of your API. An error type wraps through Unwrap draws that line.

Practice

A warehouse reads pick lines of the form sku quantity. Write parsePick(line int, text string) (int, error) that wraps strconv.Atoi's error with the line number when the quantity is not a number, and wraps a sentinel ErrBadQuantity when it is not positive. From main, sort each line's failure with errors.Is — not a number (strconv.ErrSyntax), bad quantity, or other — and for one failure print every layer of its chain with a loop over errors.Unwrap until it returns nil.

Solution

wrap_with_w_unwrap_with_is_kata_go.go in full — pasted here by tools/run_examples.py from the file CI runs.

// Kata: pick lines. parsePick wraps strconv's error when the quantity is not
// a number and ErrBadQuantity when it is not positive; the caller sorts the
// failures with errors.Is and peels one chain apart with errors.Unwrap.
//
//  go build wrap_with_w_unwrap_with_is_kata_go.go && ./wrap_with_w_unwrap_with_is_kata_go
package main

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

var ErrBadQuantity = errors.New("quantity must be positive")

// parsePick reads "sku quantity" and returns the quantity.
func parsePick(line int, text string) (int, error) {
    fields := strings.Fields(text)
    if len(fields) != 2 {
        return 0, fmt.Errorf("line %d: want sku and quantity, got %q", line, text)
    }
    n, err := strconv.Atoi(fields[1])
    if err != nil {
        return 0, fmt.Errorf("line %d: %w", line, err)
    }
    if n <= 0 {
        return 0, fmt.Errorf("line %d: quantity %d: %w", line, n, ErrBadQuantity)
    }
    return n, nil
}

func main() {
    lines := []string{"lamp 4", "desk x", "chair -1", "shelf"}
    var chain error
    for i, text := range lines {
        n, err := parsePick(i+1, text)
        switch {
        case err == nil:
            fmt.Printf("line %d: pick %d\n", i+1, n)
        case errors.Is(err, ErrBadQuantity):
            fmt.Printf("line %d: bad quantity\n", i+1)
        case errors.Is(err, strconv.ErrSyntax):
            fmt.Printf("line %d: not a number\n", i+1)
            chain = err
        default:
            fmt.Printf("line %d: other: %v\n", i+1, err)
        }
    }
    fmt.Println("the chain behind line 2, one Unwrap at a time:")
    for layer := chain; layer != nil; layer = errors.Unwrap(layer) {
        fmt.Printf("  %T: %v\n", layer, layer)
    }
}

Verified output of wrap_with_w_unwrap_with_is_kata_go.go — regenerated by tools/run_examples.py, never hand-typed.

line 1: pick 4
line 2: not a number
line 3: bad quantity
line 4: other: line 4: want sku and quantity, got "shelf"
the chain behind line 2, one Unwrap at a time:
  *fmt.wrapError: line 2: strconv.Atoi: parsing "x": invalid syntax
  *strconv.NumError: strconv.Atoi: parsing "x": invalid syntax
  *errors.errorString: invalid syntax

In other languages

  • The Rust library's What makes a type an error ↗ — Error::source() is Rust's Unwrap: one level down, and a caller walks it in a loop; there is no errors.Is, so matching a cause means downcast_ref on each level. anyhow and context ↗ — .context("fulfil order 7") is fmt.Errorf("fulfil order 7: %w", err), and The ? operator ↗ is if err != nil { return err } in one character.
  • The C library's Parsing a number from text ↗ — errno is one global integer with no chain: nothing wraps it, and the caller must read it before anything else overwrites it.
  • Python chains exceptions with raise NewError("…") from err, which sets __cause__ (the tutorial ↗) — that is %w; but except NotFoundError matches by class and inheritance, not by walking that chain.

Sources