Error Wrapping and errors.Is errors.As
A failure that surfaces three layers above where it occurred is useless without context: “permission denied” tells you nothing if you cannot see it was opening the config file during application startup. Before Go 1.13 (released 17 September 2019, per the release notes) the only way to add context was
fmt.Errorf("...: %v", err), which flattened the underlying error into a string and destroyed the ability to inspect it programmatically. Go 1.13 fixed this with error wrapping: the%wverb infmt.Errorfproduces an error that contains the original error rather than stringifying it, and three functions —errors.Unwrap,errors.Is, anderrors.As— let callers walk that chain and ask “is the root cause this sentinel?” or “is any error in here this type?” without parsing strings. Go 1.20 (February 2023) addederrors.Joinfor wrapping multiple errors, and Go 1.26 (February 2026) added the genericerrors.AsType. The core idea: wrapping turns an error from a string into an inspectable tree of causes.
Mental Model
A wrapped error is a linked structure of causes. fmt.Errorf("...: %w", inner) builds a node that holds a message and a pointer to inner; inner may itself wrap something deeper. errors.Join builds a node holding several children. The result is a tree (a chain, in the common single-%w case). errors.Unwrap steps one link; errors.Is and errors.As walk the whole tree so a caller never has to know how deep the cause is buried.
graph TD A["fmt.Errorf("startup: %w", B)<br/>top-level error"] --> B["fmt.Errorf("load config: %w", C)"] B --> C["fmt.Errorf("open /etc/app.conf: %w", D)"] C --> D["fs.ErrPermission<br/>(sentinel, leaf)"] style D fill:#fdd A -.->|"errors.Is(A, fs.ErrPermission)<br/>walks A→B→C→D, returns true"| D A -.->|"errors.As(A, &pathErr)<br/>finds first *fs.PathError in chain"| C
Diagram: a four-deep error chain built by nested %w wrapping. The insight: each layer adds human context (“startup”, “load config”, “open …”) without losing the machine-inspectable leaf (fs.ErrPermission). errors.Is and errors.As traverse from the top down so the caller checks the root cause with one call, regardless of how many context layers sit above it.
The Unwrap Convention
Wrapping rests on one convention: an error that contains another implements an Unwrap method. The Go 1.13 blog post states it: “If e1.Unwrap() returns e2, then we say that e1 wraps e2, and that you can unwrap e1 to get e2.” Two method shapes are recognised:
Unwrap() error // wraps exactly one error
Unwrap() []error // wraps several (used by errors.Join)The single-error form is the original Go 1.13 convention; the []error form was added alongside errors.Join in Go 1.20. You rarely write Unwrap by hand — fmt.Errorf("%w", err) and errors.Join produce types (fmt.wrapError, the internal joinError) that already implement it.
The %w Verb
fmt.Errorf gained the %w (“wrap”) verb. The blog post: “the error returned by fmt.Errorf has an Unwrap method returning the argument of %w, which must be an error.” Compare the two forms:
return fmt.Errorf("decompress %v: %v", name, err) // %v: flattens — err's identity LOST
return fmt.Errorf("decompress %v: %w", name, err) // %w: wraps — err recoverableBoth produce the same string. They differ entirely in what the value retains. With %v, err is converted to text and discarded; the returned error is a flat *errorString-like value and errors.Is/As against the underlying cause will fail. With %w, the returned *fmt.wrapError keeps a live reference to err, and the chain is walkable. As of Go 1.20 fmt.Errorf accepts multiple %w verbs in one format string, producing an error whose Unwrap returns []error.
errors.Unwrap, errors.Is, errors.As
The verified errors package documentation gives the current signatures and behaviour.
func Unwrap(err error) error — “returns the result of calling the Unwrap method on err, if err’s type contains an Unwrap method returning error. Otherwise, Unwrap returns nil.” It steps exactly one link, and only the Unwrap() error form: “In particular Unwrap does not unwrap errors returned by Join” — because Join’s errors implement Unwrap() []error, the shape errors.Unwrap ignores. You rarely call Unwrap directly; Is/As are preferred.
func Is(err, target error) bool — “Is reports whether any error in err’s tree matches target. The target must be comparable. 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, Is examines err followed by a depth-first traversal of its children. An error is considered to match a target if it is equal to that target or if it implements a method Is(error) bool such that Is(target) returns true.” Use it for sentinel comparison — it is the wrapping-aware replacement for err == ErrFoo.
func As(err error, target any) bool — “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.” target must be a pointer to either an error type or an interface. It is the wrapping-aware replacement for the type assertion e, ok := err.(*T). “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.” As panics if target is not a non-nil pointer to an error-implementing type or an interface.
The custom hooks matter. An error may implement Is(target error) bool to define semantic matching (e.g. an HTTP error matching any &HTTPError{Code: 404} regardless of message), or As(any) bool for custom extraction. errors.Is/As consult these methods if present, otherwise fall back to == / type-assignability.
errors.Join — Wrapping Multiple Errors
Go 1.20 added func Join(errs ...error) error. The docs: “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 … with a newline between each string. A non-nil error returned by Join implements the Unwrap() []error method.” It is the tool for accumulating independent failures — validating ten fields, closing five resources — into one error that Is/As can still search across all branches.
Go 1.26: errors.AsType
The Go 1.26 baseline adds a generic, type-parameterized convenience: func AsType[E error](err error) (E, bool) (annotated “added in go1.26.0” in the errors package documentation, and described in the Go 1.26 release notes as “a generic version of As. It is type-safe, faster, and, in most cases, easier to use”). The package docs give its behaviour verbatim: “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.” An error matches 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.” It is errors.As without the out-pointer dance — pe, ok := errors.AsType[*fs.PathError](err) instead of declaring var pe *fs.PathError then errors.As(err, &pe).
The change runs deeper than convenience: as of Go 1.26 the documentation for errors.As itself now opens with “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” (errors docs). In other words AsType is now the recommended extraction primitive, and As is positioned as the fallback for the cases AsType cannot express — chiefly when you want to extract into an interface type rather than a concrete error type, since E in AsType[E error] is constrained to implement error. Three differences are worth internalizing. First, type safety: the target type is a compile-time type parameter, so there is no any-typed out-pointer to get wrong and no way to trigger the As panic by passing a non-pointer. Second, speed: avoiding the reflect-driven assignability check that As performs on its any target is what the release notes mean by “faster.” Third, ergonomics: the matched value is the return value, so it composes in an if-with-init (if pe, ok := errors.AsType[*fs.PathError](err); ok { ... }) without a separate var declaration.
Code Examples
Building and inspecting a chain
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func loadConfig(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("load config %q: %w", path, err) // line 1
}
return nil
}
func startup() error {
if err := loadConfig("/etc/app.conf"); err != nil {
return fmt.Errorf("startup: %w", err) // line 2
}
return nil
}
func main() {
err := startup()
fmt.Println(err) // line 3
if errors.Is(err, fs.ErrNotExist) { // line 4
fmt.Println("config file is missing")
}
var pe *fs.PathError
if errors.As(err, &pe) { // line 5
fmt.Printf("failed op=%q path=%q\n", pe.Op, pe.Path)
}
}- Line 1 —
%wwraps the*fs.PathErrorthatos.Openreturns; the returned error is a*fmt.wrapErrorwhoseUnwrap()yields thatPathError. - Line 2 — wraps again. The chain is now
startup-wrapError → loadConfig-wrapError → *fs.PathError → fs.ErrNotExist. - Line 3 — prints
startup: load config "/etc/app.conf": open /etc/app.conf: no such file or directory— every layer’s context, concatenated. - Line 4 —
errors.Iswalks the chain and findsfs.ErrNotExistat the leaf (*fs.PathErrorimplements anIsmethod matching it). One call, regardless of two wrapping layers. - Line 5 —
errors.Aswalks the chain, finds the*fs.PathError, and assigns it tope;pe.Op("open") andpe.Pathare then readable structured data.
Joining independent failures
func validate(u User) error {
var errs []error
if u.Email == "" {
errs = append(errs, ErrEmailRequired)
}
if u.Age < 0 {
errs = append(errs, ErrAgeNegative)
}
return errors.Join(errs...) // line A: nil if errs is empty
}
err := validate(u)
if errors.Is(err, ErrEmailRequired) { // line B: searches all branches
// ...
}- Line A —
errors.Joinreturnsnilwhenerrsis empty (all-nil discarded), so the success path is clean; otherwise a multi-error whoseUnwrap()returns[]error. - Line B —
errors.Isdoes a depth-first traversal across both joined children, so it findsErrEmailRequiredeven thoughErrAgeNegativeis also present.
A custom Is method for semantic matching
type HTTPError struct {
Code int
Msg string
}
func (e *HTTPError) Error() string { return fmt.Sprintf("%d: %s", e.Code, e.Msg) }
// Is lets errors.Is match on Code alone, ignoring Msg.
func (e *HTTPError) Is(target error) bool {
t, ok := target.(*HTTPError)
return ok && e.Code == t.Code
}
// caller — matches ANY 404, whatever the message:
if errors.Is(err, &HTTPError{Code: 404}) { /* ... */ }The Is method overrides the default == comparison, so a 404 from any source matches the probe — exactly the pattern the Go 1.13 blog post demonstrates with its *os.PathError-style example.
How errors.Is and errors.As Actually Walk the Tree
It is worth tracing the traversal precisely, because the “tree” language in the docs hides a real algorithm. errors.Is(err, target) starts at err and, at each node, performs the match test: is this node ==-equal to target, or does it have an Is(error) bool method returning true for target? If yes, return true. If no, it tries to descend: it looks for an Unwrap() error method (descend to the single child) or an Unwrap() []error method (descend into each child). For the multi-child case the traversal is depth-first — err, then err’s first child and its entire subtree, then err’s second child, and so on. When no node matches and the tree is exhausted, Is returns false. errors.As runs the identical traversal but its match test is “is this node’s concrete value assignable to *target, or does it have an As(any) bool method that succeeds” — and on the first match it writes the node into *target and stops. The “must be comparable” requirement on errors.Is’s target exists because the default match test uses ==; an uncomparable target (a struct with a slice field) without a custom Is method makes that == panic. This is why sentinels are always pointers — pointer comparison is always defined and cheap.
A subtle consequence: because As returns the first match in depth-first order, and Is/As descend into every branch of a joined error, the order you pass errors to errors.Join is the order As will find them. If two joined errors are both *MyError, errors.As extracts the one passed first.
When to Wrap — the Design Discipline
The Go 1.13 blog post frames wrapping as an API decision, not a formatting choice: “wrapping an error makes that error part of your API. If you don’t want to commit to supporting that error as part of your API in the future, you shouldn’t wrap the error.” Concretely: if your package calls an internal dependency and wraps its error with %w, every caller can now errors.Is/As through your package down to that dependency’s error type — and if you later swap the dependency, those callers break. Using %v instead severs the chain: the message is preserved, the cause’s identity is hidden, and you remain free to change internals. So the rule is: wrap (%w) when you intend callers to inspect the cause (e.g. you want errors.Is(err, sql.ErrNoRows) to keep working through your data layer); flatten (%v) when the cause is an implementation detail you do not want to promise. This is the same reasoning that governs which identifiers you export — wrapping is exporting an error’s identity.
Failure Modes and Common Misunderstandings
Using %v when you meant %w. fmt.Errorf("...: %v", err) compiles fine and prints identically, but the wrap is lost — errors.Is/As against the underlying cause silently return false. This is the single most common wrapping bug. If callers should inspect the cause, use %w; if you deliberately want to hide the cause (so it is not part of your API), use %v — that choice is intentional, per the blog post’s “wrapping makes the error part of your public API” warning.
errors.As with the wrong target. As needs a pointer to an error type or interface. Passing the error value itself (errors.As(err, target) instead of &target), or a non-pointer, panics. The target’s pointed-to type is what As matches against.
errors.Unwrap does not see Join. errors.Unwrap only calls Unwrap() error; Join’s errors implement Unwrap() []error. To inspect a joined error, use Is/As, or type-assert to interface{ Unwrap() []error }.
errors.Is target must be comparable. If target is a value of an uncomparable type (a struct with a slice field, used directly as the target without an Is method), errors.Is can panic on the == step. Sentinels are pointers and thus always comparable; custom matchers should provide an Is method.
Over-wrapping. Wrapping at every call site produces messages like a: b: c: d: e: real error. Wrap where you add genuine context (a crossed abstraction boundary, a parameter value worth recording); pass the error through untouched otherwise.
Wrapping makes the cause public API. Once you %w a sentinel or type, callers may depend on errors.Is/As finding it — you have committed to keeping it. To change an internal error without breaking callers, do not wrap it; flatten with %v.
Alternatives and When to Choose Them
Before Go 1.13, the community used github.com/pkg/errors, which offered Wrap/Cause and attached stack traces. The standard %w/Is/As API supersedes the wrapping half but deliberately omits stack traces — the Go team’s position is that traces are a separate concern. Teams that want traces still use a custom error type capturing runtime.Callers, or libraries like cockroachdb/errors. For single-cause context use %w; for multiple independent failures use errors.Join; for comparing a sentinel use errors.Is; for extracting structured data use errors.As (or errors.AsType on Go 1.26+). A bare == or type assertion still works for unwrapped errors and is marginally faster, but is fragile the moment any layer starts wrapping — prefer Is/As as the default.
Production Notes
The Go 1.13 error API came out of the Go 2 “error inspection” design proposal (golang/proposal issue 29934). The pattern that dominates production Go: each layer that crosses a meaningful boundary wraps with fmt.Errorf("doing X: %w", err), public sentinels (io.EOF, sql.ErrNoRows, fs.ErrNotExist) are checked with errors.Is, and structured errors (*fs.PathError, *json.SyntaxError, custom domain errors) are extracted with errors.As. errors.Join is now standard for validation and for resource-cleanup paths (defer closing several handles, joining their errors). As of Go 1.26 (latest patch 1.26.3, released 7 May 2026, per the release history), the API surface is errors.New (1.0), Unwrap/Is/As (1.13), Join (1.20), and AsType (1.26) — a deliberately small, stable toolkit built entirely on the one-method error interface. Go 1.26 also tuned the hot path: the release notes record that “for unformatted strings, fmt.Errorf("x") now allocates less and generally matches the allocations for errors.New("x")” (Go 1.26 release notes) — closing a long-standing reason to reach for errors.New over fmt.Errorf purely for allocation count.
See Also
- The Error Interface — the sibling note: the
errorinterface and how error values are constructed - Custom Error Types and Sentinel Errors — designing the error values that wrapping operates on
- Type Assertions and Type Switches — the static counterpart of
errors.As - Interface Internals — why sentinel
==comparison works (pointer identity) - panic and recover — the mechanism for unrecoverable failure, distinct from error values
- Generics and Type Parameters — the type-parameter machinery behind
errors.AsType - Go 1.26 Release Notes — where
errors.AsTypelanded - Go Internals MOC — parent map of content