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 (or Unwrap() []error) and Is(error) bool — plus a convention. fmt.Errorf("...: %w", err) produces a *wrapError that holds both a message and the cause; errors.Is and errors.As walk the resulting tree; errors.Join gives 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

  • %w is only legal in fmt.Errorf (that’s what p.wrapErrs gates).
  • %w requires an operand that implements error.
  • %w is otherwise a synonym for %v — the formatting is identical.

After the format is rendered, errorf builds the right wrapper:

go
// 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 just errors.New(s), a plain error with a message and no cause. This is why fmt.Errorf("oops: %v", err) does not wrap; it discards the cause entirely.
  • One %w — a *wrapError holding the formatted message and the cause.
  • Two or more %w — a *wrapErrors holding the message and a slice of causes. This is the newer capability: a single fmt.Errorf call can produce a multi-error, and the tree gets branches.

And the wrappers themselves are tiny:

go
// 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:

go
// 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 walks the tree looking for a match:

go
// 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:

go
// 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:

go
// 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:

text
                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
     false

errors.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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

VerbIn fmt.ErrorfResult
%vFormats the error into the messageNo wrapping. The cause is discarded.
%+vFormats the error into the messageNo wrapping. Identical to %v for a plain error.
%wFormats the error into the messageWrapping. 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:

go
// 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:

go
// 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:

go
// 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 Is or As method and control how it’s matched. syscall.Errno matches 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.Is on a deep chain is O(depth), and on a joined error it’s O(branches × depth). It is not a lookup.
  • errors.Is and == 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/errors add them; the standard library does not.
  • No withMessage or withStack. Those helpers come from github.com/pkg/errors, not from src/errors. The standard library’s equivalent of withMessage is fmt.Errorf("...: %w", err); the equivalent of withStack is a custom error type of your own that calls runtime.Callers and 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 contract
  • src/errors/wrap.go — Unwrap, Is, As, AsType and their recursive walks
  • src/errors/join.go — Join, joinError and the multi-error Unwrap
  • src/fmt/errors.go — Errorf, errorf, wrapError, wrapErrors
  • src/fmt/print.go — handleMethods, catchPanic, the %w interception in the format loop
  • src/syscall/syscall_unix.go — Errno.Is, the canonical custom Is method
  • go.dev/blog/go1.13-errors — the design rationale for the whole design

GoInternalsErrorsIdiomatic goStdlib