Error handling5 min read

Go Error Trees: Use errors.Is, errors.As, and errors.Join

Inspect wrapped and joined Go errors by identity or type, preserve context with %w, and make deliberate error API commitments.

  • errors
  • API design
  • standard library

Go errors are values, and a modern Go error can expose a tree of other errors. Use errors.Is to ask whether any node represents a target value, and use errors.As or Go 1.26’s errors.AsType to retrieve the first node of a target type. Do not parse Error() text or repeatedly call errors.Unwrap when the decision is semantic.

This article targets Go 1.27. Single-error wrapping, errors.Is, and errors.As require Go 1.13 or newer. Multiple wrapped children and errors.Join require Go 1.20. The generic errors.AsType helper requires Go 1.26.

An error exposes behavior, not just text

The predeclared error interface requires only one method:

type error interface {
	Error() string
}

That small interface allows sentinel values, structured error types, and wrappers to share the same return type. The Go specification defines the interface, while the standard errors package defines tree inspection.

A wrapper opts into traversal with one of two methods:

Unwrap() error
Unwrap() []error

The first shape adds one child. The second adds several. errors.Is and errors.As inspect the root first, then walk children depth-first. That is why the useful model is a tree rather than a formatted string or necessarily a single chain.

Wrap context without losing identity

fmt.Errorf with %w adds human context and retains a child for programmatic inspection. This complete program builds a tree with two leaves:

package main

import (
	"errors"
	"fmt"
)

var ErrUnavailable = errors.New("service unavailable")

type RetryError struct {
	AfterSeconds int
	Err          error
}

func (e *RetryError) Error() string {
	return fmt.Sprintf("retry after %d seconds: %v", e.AfterSeconds, e.Err)
}

func (e *RetryError) Unwrap() error { return e.Err }

func main() {
	primary := fmt.Errorf("load profile: %w", ErrUnavailable)
	retry := &RetryError{AfterSeconds: 5, Err: primary}
	cleanup := errors.New("close response body")
	err := errors.Join(retry, cleanup)

	fmt.Println(errors.Is(err, ErrUnavailable))

	var target *RetryError
	if errors.As(err, &target) {
		fmt.Println(target.AfterSeconds)
	}
}

The output is:

true
5

The root returned by errors.Join has two children. One child is a RetryError, which wraps the sentinel through the contextual error. errors.Is reaches the sentinel without callers knowing the intermediate types. errors.As finds the first assignable *RetryError and stores it through &target.

In Go 1.26 and later, the type-safe equivalent is shorter:

if target, ok := errors.AsType[*RetryError](err); ok {
	fmt.Println(target.AfterSeconds)
}

The current errors.As documentation recommends AsType for most cases because a type argument is less error-prone than As’s pointer-shaped target. Keep As when supporting Go 1.25 or earlier, or when the target itself is an interface that does not implement error.

Is tests meaning; As retrieves structure

Choose the operation by what the caller needs:

  • errors.Is(err, target) answers whether a target value is represented anywhere in the tree. It normally replaces err == target when wrapping is allowed.
  • errors.As(err, &target) retrieves the first node assignable to the requested type. It normally replaces a direct type assertion when wrappers may intervene.
  • errors.AsType[T](err) performs the type search without a pointer-to-target argument on Go 1.26 and later.

An error type may customize matching with Is(error) bool or As(any) bool. The errors.Is contract says a custom Is method should compare only itself and the target; it should not recursively unwrap either value. Traversal belongs to the package so that every node follows the same order.

The target passed to errors.Is must be comparable. For conditions that need fields or richer data, a named error type plus As is usually clearer than manufacturing a complicated sentinel.

Joining errors preserves peer failures

errors.Join(a, b) is appropriate when both failures matter, such as an operation failure followed by a cleanup failure. It discards nil operands, returns nil when all operands are nil, and returns an error whose Unwrap() []error exposes the non-nil children. Its text joins child messages with newlines, but callers should rely on Is or As, not that presentation.

Go 1.20 also allowed multiple %w verbs in one fmt.Errorf call. The Go 1.20 release notes describe both multi-%w and errors.Join. Use fmt.Errorf when one formatted sentence is the natural presentation, and errors.Join when the errors are peers. Both produce a multi-child tree.

One trap is errors.Unwrap. It calls only a method shaped Unwrap() error; it deliberately does not unpack Unwrap() []error. Code that loops on errors.Unwrap therefore misses joined branches. Prefer the tree-aware operations unless an API specifically needs exactly one immediate child.

Wrapping is an API decision

Changing %v to %w keeps similar text but changes what callers can depend on. If a package wraps sql.ErrNoRows, for example, callers can build control flow around errors.Is. Replacing the database later may then require preserving that behavior.

The official guidance on working with errors in Go 1.13 frames wrapping as exposing an underlying error to callers. Wrap a dependency error when that identity or type belongs in your contract. Otherwise translate it to your own sentinel or type, or retain its message with %v without exposing it to traversal.

Document the stable property explicitly: “returns an error wrapping ErrNotFound,” “returns a *ParseError,” or simply “returns a non-nil error.” That lets callers use Is and As without treating incidental tree nodes as permanent API.

Keep trees useful

Useful error trees preserve causes that callers can act on, add context at meaningful boundaries, and keep peer failures when discarding one would hide work the program must address. They become noisy when every frame wraps mechanically, when implementation-only errors leak through public APIs, or when code treats joined error text as a serialization format.

Check err != nil for success or failure. Use Is for a stable condition, As or AsType for structured details, and Join for independent failures. Those choices preserve both readable diagnostics and machine-checkable meaning.