Go's Error Wrapping: %w, errors.Is and a Tree of Causes
Go's error handling is just two optional methods, Unwrap and Is, plus a tree-shaped convention: fmt.Errorf builds a *wrapError with %w, errors.Is and errors.As walk that tree, and errors.Join gives it branches.
TL;DR: Go’s error wrapping is not a framework. It is two optional methods —
Unwrap() error(orUnwrap() []error) andIs(error) bool— plus a convention.fmt.Errorf("...: %w", err)produces a*wrapErrorthat holds both a message and the cause;errors.Isanderrors.Aswalk the resulting tree;errors.Joingives the tree branches. Everything else is ordinary Go code.
There is no error hierarchy in Go, no exception hierarchy, no error
registry. An error is a value. Any value that has an Error() string method
is an error, and that’s the entire contract.
What Go does have, since 1.13, is a convention for chaining: an error can wrap other errors, and the standard library provides two functions for searching the resulting structure. Understanding that structure — which is remarkably small — explains most of the “which error function should I use?” confusion.
This is a walk through how it all works, based on reading the Go source tree
(Go 1.28 development tree, commit c0be4674).
Errors are values
The whole contract is one method:
// src/errors/errors.go:62
// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
return &errorString{text}
}
// errorString is a trivial implementation of error.
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}That’s errors.New in full. A struct with one string field and an Error()
method. Nothing more.
The errors package’s own documentation states the wrapping contract
precisely, and it’s worth reading because it is the whole spec:
// src/errors/errors.go:9
// An error e wraps another error if e's type has one of the methods
//
// Unwrap() error
// Unwrap() []error
//
// If e.Unwrap() returns a non-nil error w or a slice containing w,
// then we say that e wraps w. A nil error returned from e.Unwrap()
// indicates that e does not wrap any error. It is invalid for an
// Unwrap method to return an []error containing a nil error value.
//
// An easy way to create wrapped errors is to call [fmt.Errorf] and apply
// the %w verb to the error argument:
//
// wrapsErr := fmt.Errorf("... %w ...", ..., err, ...)
//
// Successive unwrapping of an error creates a tree. The [Is] and [As]
// functions inspect an error's tree by examining first the error
// itself followed by the tree of each of its children in turn
// (pre-order, depth-first traversal).A tree. Not a chain, not a list — a tree. That’s the key mental
model, and it’s the reason errors.Join exists and the reason Is and
As have to be written as recursive searches rather than loops.
Note also “pre-order, depth-first traversal”: the error itself is checked first, then its children in order.
What fmt.Errorf does with %w
fmt.Errorf is where wrapping normally happens. The interesting part is
that fmt has to record which arguments were %w verbs, so it keeps a
list of argument indices:
// src/fmt/print.go:126
// reordered records whether the format string used argument reordering.
reordered bool
// goodArgNum records whether the most recent reordering directive was valid.
goodArgNum bool
// panicking is set by catchPanic to avoid infinite panic, recover, panic, ... recursion.
panicking bool
// erroring is set when printing an error string to guard against calling handleMethods.
erroring bool
// wrapErrs is set when the format string may contain a %w verb.
wrapErrs bool
// wrappedErrs records the targets of the %w verb.
wrappedErrs []int
}The %w verb is intercepted in the fast path of the formatting loop:
// src/fmt/print.go:1053
if 'a' <= c && c <= 'z' && argNum < len(a) {
switch c {
case 'w':
p.wrappedErrs = append(p.wrappedErrs, argNum)
fallthrough
case 'v':
// Go syntax
p.fmt.sharpV = p.fmt.sharp
p.fmt.sharp = false
// Struct-field syntax
p.fmt.plusV = p.fmt.plus
p.fmt.plus = false
}
p.printArg(a[argNum], rune(c))
argNum++
i++
continue formatLoop
}and then, in the slow path, the %w case in handleMethods decides what
the verb means:
// src/fmt/print.go:614
func (p *pp) handleMethods(arg any, value reflect.Value, verb rune) (handled bool) {
if p.erroring {
return
}
if verb == 'w' {
// It is invalid to use %w other than with Errorf or with a non-error arg.
_, ok := arg.(error)
if !ok || !p.wrapErrs {
p.badVerb(arg, value, verb)
return true
}
// If the arg is a Formatter, pass 'v' as the verb to it.
verb = 'v'
}Three rules fall out of this:
%wis only legal infmt.Errorf(that’s whatp.wrapErrsgates).%wrequires an operand that implementserror.%wis otherwise a synonym for%v— the formatting is identical.
After the format is rendered, errorf builds the right wrapper:
// src/fmt/errors.go:34
// errorf formats and returns an error value, or nil if no formatting is required.
func errorf(format string, a ...any) error {
if len(a) == 0 && stringslite.IndexByte(format, '%') == -1 {
return nil
}
p := newPrinter()
p.wrapErrs = true
p.doPrintf(format, a)
s := string(p.buf)
var err error
switch len(p.wrappedErrs) {
case 0:
err = errors.New(s)
case 1:
w := &wrapError{msg: s}
w.err, _ = a[p.wrappedErrs[0]].(error)
err = w
default:
if p.reordered {
slices.Sort(p.wrappedErrs)
}
var errs []error
for i, argNum := range p.wrappedErrs {
if i > 0 && p.wrappedErrs[i-1] == argNum {
continue
}
if e, ok := a[argNum].(error); ok {
errs = append(errs, e)
}
}
err = &wrapErrors{s, errs}
}
p.free()
return err
}Three cases:
- No
%w— the result is justerrors.New(s), a plain error with a message and no cause. This is whyfmt.Errorf("oops: %v", err)does not wrap; it discards the cause entirely. - One
%w— a*wrapErrorholding the formatted message and the cause. - Two or more
%w— a*wrapErrorsholding the message and a slice of causes. This is the newer capability: a singlefmt.Errorfcall can produce a multi-error, and the tree gets branches.
And the wrappers themselves are tiny:
// src/fmt/errors.go:70
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string {
return e.msg
}
func (e *wrapError) Unwrap() error {
return e.err
}
type wrapErrors struct {
msg string
errs []error
}
func (e *wrapErrors) Error() string {
return e.msg
}
func (e *wrapErrors) Unwrap() []error {
return e.errs
}Note what’s not there: no stack trace, no frame list, no cause chain field. Just a message and a pointer to the next error.
Also worth noticing: fmt.Errorf has a fast path that avoids the whole
formatting machinery when there’s nothing to format:
// src/fmt/errors.go:23
func Errorf(format string, a ...any) (err error) {
// This function has been split in a somewhat unnatural way
// so that both it and the errors.New call can be inlined.
if err = errorf(format, a...); err != nil {
return err
}
// No formatting was needed. We can avoid some allocations and other work.
// See https://go.dev/cl/708836 for details.
return errors.New(format)
}errorf returns nil when there are no arguments and no % in the
format, which is the signal to fall back to errors.New.
errors.Is: the tree search
errors.Is walks the tree looking for a match:
// src/errors/wrap.go:45
func Is(err, target error) bool {
if err == nil || target == nil {
return err == target
}
isComparable := reflectlite.TypeOf(target).Comparable()
return is(err, target, isComparable)
}
func is(err, target error, targetComparable bool) bool {
for {
if targetComparable && err == target {
return true
}
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
switch x := err.(type) {
case interface{ Unwrap() error }:
err = x.Unwrap()
if err == nil {
return false
}
case interface{ Unwrap() []error }:
for _, err := range x.Unwrap() {
if is(err, target, targetComparable) {
return true
}
}
return false
default:
return false
}
}
}Four things to unpack.
One. The comparison err == target is guarded by targetComparable.
Comparing two interface values whose dynamic type is not comparable (a
slice, a map, a function) panics at runtime. reflectlite.TypeOf(target).Comparable()
is checked once, up front, so the loop can use == safely.
Two. Before descending, Is checks whether the current error has its
own Is(error) bool method, and asks it. This is the escape hatch that
lets a custom error type claim to be a sentinel:
// src/syscall/syscall_unix.go:120
func (e Errno) Is(target error) bool {
switch target {
case oserror.ErrPermission:
return e == EACCES || e == EPERM
case oserror.ErrExist:
return e == EEXIST || e == ENOTEMPTY
case oserror.ErrNotExist:
return e == ENOENT
case errorspkg.ErrUnsupported:
return e == ENOSYS || e == ENOTSUP || e == EOPNOTSUPP
}
return false
}syscall.EEXIST and syscall.ENOTEMPTY are different errno values, but
both mean “the file already exists”, so errors.Is(err, fs.ErrExist)
succeeds for either. Without the Is method, only the exact value would
match.
The docs are explicit about the contract for such a method:
// src/errors/wrap.go:42
// then Is(MyError{}, fs.ErrExist) returns true. See [syscall.Errno.Is] for
// an example in the standard library. An Is method should only shallowly
// compare err and the target and not call [Unwrap] on either.Shallow only. If your Is method calls Unwrap, you get exponential
behaviour on deep chains.
Three. The switch on the error’s dynamic type handles the two
Unwrap shapes. Unwrap() error is a linear descent, so the loop simply
reassigns err and continues. Unwrap() []error is a branch, so it
recurses into each child in order.
Four. The default: return false case means an error with no Unwrap
method is a leaf, and the search stops there.
Here is the whole algorithm as a picture:
errors.Is(err, target)
|
v
+---------------+---------------+
| |
err == target? err has Is(error) bool?
| |
yes -> true x.Is(target) -> true
|
v
err.Unwrap() error ? ------yes----> loop with the child
|
no
v
err.Unwrap() []error ? --yes--> for each child: errors.Is(child, target)
|
no
v
falseerrors.As: matching by type
errors.As is the same walk, but instead of comparing to a fixed target
it matches on type and hands the matching value back to you:
// src/errors/wrap.go:102
func As(err error, target any) bool {
if err == nil {
return false
}
if target == nil {
panic("errors: target cannot be nil")
}
val := reflectlite.ValueOf(target)
typ := val.Type()
if typ.Kind() != reflectlite.Ptr || val.IsNil() {
panic("errors: target must be a non-nil pointer")
}
targetType := typ.Elem()
if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) {
panic("errors: *target must be interface or implement error")
}
return as(err, target, val, targetType)
}
func as(err error, target any, targetVal reflectlite.Value, targetType reflectlite.Type) bool {
for {
if reflectlite.TypeOf(err).AssignableTo(targetType) {
targetVal.Elem().Set(reflectlite.ValueOf(err))
return true
}
if x, ok := err.(interface{ As(any) bool }); ok && x.As(target) {
return true
}
switch x := err.(type) {
case interface{ Unwrap() error }:
err = x.Unwrap()
if err == nil {
return false
}
case interface{ Unwrap() []error }:
for _, err := range x.Unwrap() {
if err == nil {
continue
}
if as(err, target, targetVal, targetType) {
return true
}
}
return false
default:
return false
}
}
}AssignableTo rather than an exact type match — so asking for an
interface type finds the first error in the tree that implements it,
which is the common case. The package documentation describes the match
rule like this:
// src/errors/wrap.go:81
// As finds the first error in err's tree that matches target, and if one is found, sets
// target to that error value and returns true. Otherwise, it returns false.
//
// For most uses, prefer [AsType]. As is equivalent to [AsType] but sets its target
// argument rather than returning the matching error and doesn't require its target
// argument to implement error.
//
// The tree consists of err itself, followed by the errors obtained by repeatedly
// calling its Unwrap() error or Unwrap() []error method. When err wraps multiple
// errors, As examines err followed by a depth-first traversal of its children.
//
// An error matches target if the error's concrete value is assignable to the value
// pointed to by target, or if the error has a method As(any) bool such that
// As(target) returns true. In the latter case, the As method is responsible for
// setting target.The validation at the top is strict on purpose. target must be a
non-nil pointer, and what it points to must be an interface or implement
error. That’s how the function knows where to store the result.
This tree also has the newer generic form, which avoids the pointer-to- pointer dance entirely:
// src/errors/wrap.go:154
// AsType finds the first error in err's tree that matches the type E, and
// if one is found, returns that error value and true. Otherwise, it
// returns the zero value of E and false.
//
// The tree consists of err itself, followed by the errors obtained by
// repeatedly calling its Unwrap() error or Unwrap() []error method. When
// err wraps multiple errors, AsType examines err followed by a
// depth-first traversal of its children.
//
// An error err matches the type E if the type assertion err.(E) holds,
// or if the error has a method As(any) bool such that err.As(target)
// returns true when target is a non-nil *E. In the latter case, the As
// method is responsible for setting target.
func AsType[E error](err error) (E, bool) {
if err == nil {
var zero E
return zero, false
}
var pe *E // lazily initialized
return asType(err, &pe)
}errors.AsType[*fs.PathError](err) returns the value and a bool directly.
The As function is still there and still does the same thing; AsType is
the ergonomic version.
errors.Join: adding branches
errors.Join is the other way to get a branching tree:
// src/errors/join.go:11
// Join returns an error that wraps the given errors.
// Any nil error values are discarded.
// Join returns nil if every value in errs is nil.
// The error formats as the concatenation of the strings obtained
// by calling the Error method of each element of errs, with a newline
// between each string.
//
// A non-nil error returned by Join implements the Unwrap() []error method.
// The errors may be inspected with [Is] and [As].
func Join(errs ...error) error {
n := 0
for _, err := range errs {
if err != nil {
n++
}
}
if n == 0 {
return nil
}
e := &joinError{
errs: make([]error, 0, n),
}
for _, err := range errs {
if err != nil {
e.errs = append(e.errs, err)
}
}
return e
}Note: Join of all-nil errors is nil. That’s the behaviour you want for
“collect errors as you go, return the aggregate at the end” patterns.
The formatting is newline-joined:
// src/errors/join.go:45
func (e *joinError) Error() string {
// Since Join returns nil if every value in errs is nil,
// e.errs cannot be empty.
if len(e.errs) == 1 {
return e.errs[0].Error()
}
b := []byte(e.errs[0].Error())
for _, err := range e.errs[1:] {
b = append(b, '\n')
b = append(b, err.Error()...)
}
// At this point, b has at least one byte '\n'.
return unsafe.String(&b[0], len(b))
}
func (e *joinError) Unwrap() []error {
return e.errs
}And crucially, because joinError has Unwrap() []error, errors.Is and
errors.As walk into it automatically. errors.Is(joined, fs.ErrExist)
finds fs.ErrExist if any of the joined errors wraps it.
One gotcha worth knowing: errors.Unwrap does not unwrap a joined
error:
// src/errors/wrap.go:11
// Unwrap returns the result of calling the Unwrap method on err, if err's
// type contains an Unwrap method returning error.
// Otherwise, Unwrap returns nil.
//
// Unwrap only calls a method of the form "Unwrap() error".
// In particular Unwrap does not unwrap errors returned by [Join].
func Unwrap(err error) error {
u, ok := err.(interface {
Unwrap() error
})
if !ok {
return nil
}
return u.Unwrap()
}Only Is and As understand the multi-error shape.
%v vs %+v vs %w
These three get confused constantly, and the difference is worth stating plainly:
| Verb | In fmt.Errorf | Result |
|---|---|---|
%v | Formats the error into the message | No wrapping. The cause is discarded. |
%+v | Formats the error into the message | No wrapping. Identical to %v for a plain error. |
%w | Formats the error into the message | Wrapping. The cause is retained and reachable via errors.Is/errors.As. |
The %+v row surprises people, because in the pkg/errors world %+v
prints a stack trace. In the standard library it does not. fmt has no
idea what a stack trace is.
For a plain error (one with just an Error() string method), %+v and
%v produce byte-identical output. The reason is that handleMethods
converts the error to its Error() string and then hands it to
fmtString, which only distinguishes “Go syntax” (%#v) from everything
else:
// src/fmt/print.go:481
func (p *pp) fmtString(arg any, value reflect.Value, v string, verb rune) {
switch verb {
case 'v':
if p.fmt.sharpV {
p.fmt.fmtQ(v)
} else {
p.fmt.fmtS(v)
}
case 's':
p.fmt.fmtS(v)
case 'x':
p.fmt.fmtSx(v, ldigits)
case 'X':
p.fmt.fmtSx(v, udigits)
case 'q':
p.fmt.fmtQ(v)
default:
p.badVerb(arg, value, verb)
}
}sharpV is set by %#v, not %+v. %+v only matters for structs, where
it prints field names.
If you want %+v to do something interesting, the error has to implement
fmt.Formatter — and then handleMethods hands it the verb and lets it
decide:
// src/fmt/print.go:629
// Is it a Formatter?
if formatter, ok := arg.(Formatter); ok {
handled = true
defer p.catchPanic(arg, verb, "Format")
formatter.Format(p, verb)
return
}Note the order: Formatter beats error beats Stringer. An error that
implements Format gets to decide how it prints, including for %+v.
The panic-catching safety net
One more piece of fmt machinery is worth knowing about, because it
explains a behaviour you’ve probably seen. When fmt calls your Error(),
String() or Format() method, it protects itself:
// src/fmt/print.go:580
func (p *pp) catchPanic(arg any, verb rune, method string) {
if err := recover(); err != nil {
// If it's a nil pointer, just say "<nil>". The likeliest causes are a
// Stringer that fails to guard against nil or a nil pointer for a
// value receiver, and in either case, "<nil>" is a nice result.
if v := reflect.ValueOf(arg); v.Kind() == reflect.Pointer && v.IsNil() {
p.buf.writeString(nilAngleString)
return
}
// Otherwise print a concise panic message. Most of the time the panic
// value will print itself nicely.
if p.panicking {
// Nested panics; the recursion in printArg cannot succeed.
panic(err)
}
oldFlags := p.fmt.fmtFlags
// For this output we want default behavior.
p.fmt.clearflags()
p.buf.writeString(percentBangString)
p.buf.writeRune(verb)
p.buf.writeString(panicString)
p.buf.writeString(method)
p.buf.writeString(" method: ")
p.panicking = true
p.printArg(err, 'v')
p.panicking = false
p.buf.writeByte(')')
p.fmt.fmtFlags = oldFlags
}
}A panicking Error() method doesn’t crash your program. fmt recovers and
prints something like %!v(PANIC=Error method: runtime error: ...). The
p.panicking flag prevents infinite recursion if the panic value itself
panics when printed.
That’s a nice touch, and it’s a good model: the printing machinery is untrusted code from the runtime’s point of view, because it’s calling your code.
Trade-offs
What the convention buys:
- No framework. No registration, no sentinel hierarchy, no wrapped exception types. An error is a struct with a message and maybe a cause.
- Composability. Any function can add context without knowing what
it’ll be compared against later.
fmt.Errorf("read %s: %w", path, err)works for any error. - Searchable. Because the cause chain is preserved,
errors.Is(err, fs.ErrNotExist)still works three layers down. - Customisable. An error type can define its own
IsorAsmethod and control how it’s matched.syscall.Errnomatches three different sentinels from one value.
What it costs:
- The message is a string, and strings don’t compose. You can’t get
err.Error()and then re-wrap it without losing the cause.fmt.Errorf ("%v: %w", err)keeps the cause;fmt.Errorf("%v: %s", err, msg)loses it. - Searching is a tree walk.
errors.Ison a deep chain is O(depth), and on a joined error it’s O(branches × depth). It is not a lookup. errors.Isand==are not interchangeable. Code that compares with==silently stops working the moment someone adds a%w.- No stack traces by default. This is the big one. The standard library
deliberately does not capture stack traces in errors, because capturing
them is expensive and because errors are values that get passed around.
Libraries like
pkg/errorsadd them; the standard library does not. - No
withMessageorwithStack. Those helpers come fromgithub.com/pkg/errors, not fromsrc/errors. The standard library’s equivalent ofwithMessageisfmt.Errorf("...: %w", err); the equivalent ofwithStackis a custom error type of your own that callsruntime.Callersand stores the frames.
Caveats
%w only works in fmt.Errorf. fmt.Sprintf("%w", err) produces
%!w(*errors.errorString=...). The p.wrapErrs flag in handleMethods
is what enforces this.
%w requires an error operand. fmt.Errorf("%w", 42) is a
formatting error, not a panic, but it does produce garbage output rather
than a wrapped error.
errors.Is with a non-comparable target. errors.Is(err, someSlice)
where someSlice is boxed into an error interface — the Comparable()
check prevents the panic, but the answer is always false, because a
slice can’t be equal to anything.
errors.As panics on a bad target. Passing a non-pointer, a nil
pointer, or a pointer to a non-error type is a programming error and
panics with a clear message. This is deliberate: As writes through the
pointer, so a bad target is a memory-safety issue.
errors.Join discards nils but preserves order. Join(nil, err) is
just err, and errors.Is(joined, target) will find target in either
position.
Unwrap returns nil for joined errors. If you’re walking a chain by
hand with errors.Unwrap, you’ll stop at a joinError. Use Is/As.
fmt.Errorf with multiple %w verbs reorders. Look at the
p.reordered handling in errorf: if the format string uses argument
indices like %[2]w, the wrapped errors are sorted back into argument
order before being stored, so the tree’s children are in a stable order.
Custom Is methods must be shallow. If your Is method calls
Unwrap, errors.Is will do the unwrapping again. The docs say so
explicitly.
The errors package is not special-cased anywhere. fmt doesn’t know
about it, the runtime doesn’t know about it. The whole mechanism is two
interface assertions. You can build your own equivalent in twenty lines.
Wrapping up
The one thing to remember: Go’s error wrapping is a convention, not a
framework. An error wraps another if it has an Unwrap() error or
Unwrap() []error method. fmt.Errorf with %w creates that link;
errors.Is and errors.As walk the resulting tree pre-order and
depth-first; errors.Join gives the tree branches. Everything else —
errors.New, fmt.Errorf without %w, your own error types — is just
ordinary Go.
The part people get wrong: they reach for %v when they mean %w.
fmt.Errorf("could not open %s: %v", path, err) produces an error that
looks wrapped in the log output but is not wrapped at all — errors.Is
and errors.As will find nothing. The %v and %+v verbs both discard
the cause; only %w keeps it.
What to go do about it: when you catch an error and add context, use
%w and check that the sentinel is still reachable with errors.Is. When
you don’t want the cause preserved — because you’re summarising, not
propagating — use %v deliberately. And when you define your own error
type, give it an Is method if it should match a sentinel that isn’t
literally the same value, the way syscall.Errno does.
Further reading
src/errors/errors.go—New,errorString, and the package’s specification of the wrapping contractsrc/errors/wrap.go—Unwrap,Is,As,AsTypeand their recursive walkssrc/errors/join.go—Join,joinErrorand the multi-errorUnwrapsrc/fmt/errors.go—Errorf,errorf,wrapError,wrapErrorssrc/fmt/print.go—handleMethods,catchPanic, the%winterception in the format loopsrc/syscall/syscall_unix.go—Errno.Is, the canonical customIsmethodgo.dev/blog/go1.13-errors— the design rationale for the whole design