Last Updated: May 22, 2026
Once errors get wrapped, the old habits of comparing them with == and asserting their type with err.(*MyErr) stop working. The standard library answers this with two helpers, errors.Is and errors.As, that walk the wrapped chain for you. This lesson covers what each one does, why direct comparison and type assertion break under wrapping, how built-in errors like io.EOF and os.ErrNotExist plug in, and how to customize equality with the optional Is and As methods on your own error types.
== Stops Working After WrappingAs we saw in the previous chapter, fmt.Errorf with the %w verb wraps one error inside another so the original can be recovered later. The downside is that the returned error is no longer the same value as the one you started with. Comparing it to the original with == returns false, which breaks every caller that was relying on a direct check.
Take this small inventory lookup. It returns a standard library sentinel, os.ErrNotExist, when a product can't be found, and the caller checks for that sentinel to decide what to do.
This works because lookupProduct returns the sentinel directly. The comparison err == os.ErrNotExist checks whether the two variables hold the same error value, and they do.
Now imagine the lookup grows a layer of context. A new wrapper function adds the product code to the message so the caller can see which lookup failed:
The "missing" branch never fires. fetchProduct returns a new error built by fmt.Errorf that contains os.ErrNotExist underneath, but the outer wrapper is a different value. The comparison err == os.ErrNotExist only checks the top-level error, sees they aren't the same, and returns false. The caller's behavior is now wrong even though the error semantics are identical.
The fix is to ask "does this error, or anything wrapped under it, equal os.ErrNotExist?" — which is what errors.Is does.
Same wrapping, same outer message, but the caller now sees the right branch fire. errors.Is walks the chain starting at the outer error, checks each link against the target, and returns true when it finds a match.
errors.Is Walks the ChainThe signature is errors.Is(err, target error) bool. It returns true when any error in the chain equals target. The function starts at err, compares it with target using ==, and if they don't match, it calls errors.Unwrap(err) to get the next link and repeats. The walk stops when it finds a match or runs out of links to unwrap.
The direct comparison fails because wrappedTwice is a different value from base. errors.Is walks wrappedTwice to wrapped to base, finds the match on the third step, and returns true. The third check looks for an unrelated error in the chain and correctly returns false.
The diagram shows the chain produced by two %w wraps. errors.Is walks from the outermost error toward the innermost, comparing each link to the target until it finds the sentinel. If the chain ends without a match, the function returns false. This walk is why the function works regardless of how many layers of context have been added between the original error and the caller.
Cost: errors.Is walks the chain link by link. For most error chains (one or two layers), the cost is a handful of comparisons. Don't worry about it for normal error paths; it only matters if you build chains thousands of layers deep, which you almost certainly shouldn't.
The same problem appears with type assertions. A type assertion like err.(*os.PathError) checks whether the top-level error value is of that concrete type. Once the error is wrapped, the outer value is *fmt.wrapError (the unexported type behind fmt.Errorf("...%w...")), not the type you originally returned, and the assertion fails.
The original error from os.Open is a *fs.PathError (which *os.PathError aliases). After wrapping, the outer error's dynamic type is *fmt.wrapError. The assertion against *os.PathError checks the top of the chain, not anything underneath, and falls through to the failure branch. So even though the data we want (the file path) is still in there, the standard type-assertion pattern can't reach it.
errors.As solves this by walking the chain looking for any link whose dynamic type matches the target type, then assigning the matching link into the target variable.
errors.As walks the chain, finds the *os.PathError that lives under the fmt.Errorf wrapper, and assigns it into pe. From there you have full access to the structured fields the original error carried, no matter how many layers of context were added on top.
errors.As WorksThe signature is errors.As(err error, target any) bool. The target must be a non-nil pointer to either an interface type or a concrete type that implements error. The function walks the chain. At each link, it asks "can this error be assigned to the type that target points at?" If yes, it does the assignment and returns true. If the walk ends without a match, target is left untouched and the function returns false.
A few rules to keep straight:
target is always a pointer. errors.As(err, pe) won't compile; you need errors.As(err, &pe).target points at is the place the matching error will be written. So if you want a *os.PathError, declare var pe *os.PathError and pass &pe.nil target or a non-pointer panics at runtime.errors.As picks the first match it encounters during the walk (outermost wins).The following example pulls structured data out of a wrapped chain:
loadInventory failed on the file open, so the chain contains a *os.PathError. errors.As(err, &pe) finds it and assigns it into pe, giving us the path. There's no *strconv.NumError in this chain (the parse step never ran), so the second call returns false and prints the fallback message. If the file had existed but the stock string had been "twelve", the chain would have looked different and the second errors.As would have succeeded instead.
The diagram traces what errors.As does on the chain from loadInventory. It starts at the outermost wrapper, sees it's a *fmt.wrapError and not a *os.PathError, unwraps once, hits the *os.PathError, matches the target type, copies the matching error into the caller's variable, and returns true. The walk stops as soon as the first match is found.
Cost: errors.As does a type check at every link, plus one assignment when it finds a match. Cheap for any realistic chain. Don't write it inside a hot loop for every error; use it where you actually need the structured data.
Many standard library errors already participate in this scheme, so errors.Is and errors.As work on them without any extra code on your end. The most common ones to know:
| Target | Use with | What it means |
|---|---|---|
io.EOF | errors.Is | Stream finished cleanly |
os.ErrNotExist | errors.Is | File or directory not found |
os.ErrExist | errors.Is | File or directory already exists |
os.ErrPermission | errors.Is | Permission denied |
context.Canceled | errors.Is | Context was cancelled by the caller |
context.DeadlineExceeded | errors.Is | Context hit its deadline |
*os.PathError | errors.As | Carries Op, Path, and the underlying syscall error |
*strconv.NumError | errors.As | Carries the bad input string and what was being parsed |
*net.OpError | errors.As | Carries the network op, address, and underlying cause |
*json.SyntaxError | errors.As | Carries the byte offset where parsing failed |
The standard library wraps these errors so that errors.Is finds them through any number of layers, and the structured types are designed so errors.As can pull out the useful fields. Here's a real-world style example reading a streaming order feed line by line:
bufio.Reader.ReadString returns io.EOF when the input runs out. Checking with errors.Is(err, io.EOF) is the idiomatic way to do it. Even if some intermediate layer wraps that EOF for context, the check still works because errors.Is walks the chain.
For errors.As, a similar example with *os.PathError:
Two checks on the same wrapped error. errors.As pulls out the structured *os.PathError so we can read Op and Path. errors.Is checks for the semantic condition os.ErrNotExist. They're answering different questions: "what's the shape of this error?" versus "is this error a particular kind of error?". A *os.PathError is designed to report os.ErrNotExist from its Is method when its Err field is the right syscall errno, which is why both checks succeed on the same value.
Is MethodBy default, errors.Is compares each link in the chain to the target using ==. That's fine for sentinel errors, but it isn't enough when an error type wants to be considered "equal" to a target under richer rules. For example, an error that carries a category code might want to match any target with the same code, regardless of the message. The way to do that is to implement an Is(target error) bool method on the error type.
When you call errors.Is(err, target), at each link in the chain, the function first checks link == target. If that's false and the link has an Is(error) bool method, the function calls link.Is(target) and uses its return value. If that returns true, the walk stops with a match. Otherwise, it keeps walking.
The following example uses an order error that carries a code, where any error with the same code should be considered a match:
placeOrder returns a fresh *OrderError with the customer's specific order ID, wrapped in a fmt.Errorf for context. The caller doesn't care about that order ID; they care whether the error means "out of stock". The custom Is method makes errors.Is(err, ErrOutOfStock) true for any *OrderError whose Code is "OUT_OF_STOCK", even though the two errors are different values with different OrderID fields. The standard library uses exactly this pattern for *os.PathError, whose Is method matches sentinels like os.ErrNotExist based on the underlying syscall error rather than identity.
The diagram shows what happens at a single link during the walk. Direct identity is tried first, and only if that fails does the optional Is method get a chance. The result is that you can keep the default behavior (the cheap pointer-equality check) for most types and opt into richer semantics by adding the method on the types where it matters.
A few rules of thumb for writing an Is method:
e.Is(e) returns false, callers will be surprised. The default == already handles identity, so most Is methods only need to handle "fuzzy" matches and can return false for unrelated targets.Is method itself call errors.Is on its receiver. That can recurse forever.As Methoderrors.As has a parallel mechanism. By default, it tries to assign each link in the chain to the type that target points at. If you want a type to satisfy errors.As for some target it isn't strictly assignable to (perhaps a higher-level interface, or a different concrete type the caller expects), you can add an As(target any) bool method.
This is much rarer than implementing Is. The default assignability rule covers almost every case, and most error types just rely on it. The one place a custom As method is useful is when a single error needs to be "viewable" as several different types depending on what the caller asks for. Here's a simplified example of a payment failure that can also satisfy a network failure type, useful in a service that funnels heterogeneous errors through one wrapper:
The caller can ask "is this a *NetworkFailure?" and the custom As method synthesizes one from the payment error's internal state. The caller can also ask for the underlying *PaymentError directly, and the default assignability rule handles that. Both questions are answered from the same error value.
This pattern is less common than Is, but it's good to know it exists. In most cases, keeping error types simple and letting errors.As use the default rules is sufficient; add a custom method only when the default genuinely can't express what you need.
Is, Extract Details with AsA common shape in production Go code is a small block that classifies an error two ways. First it asks errors.Is(err, sentinel) to decide what category the failure falls into. Then, if the category warrants it, it uses errors.As(err, &target) to pull out the structured fields the error type carries. The two calls operate on the same chain and don't conflict.
Here's a checkout handler that handles three categories: the cart is missing, the network failed, or something else went wrong. It uses errors.Is to identify each category and errors.As to pull out the path when a file is missing.
Each branch uses errors.Is to decide whether it applies, and the "file missing" branch additionally uses errors.As to recover the structured path. This is the typical layout you'll see in error-handling code that has more than one possible cause: classify with Is, drill in with As. The order matters a little; Is is cheaper, and most branches only need to know the category, so leading with Is keeps the common path fast.
errors.JoinMost error chains are linear: one error wraps another, which wraps another. Since Go 1.20, the standard library also supports an error that wraps multiple errors at the same level, built with errors.Join. This is useful when a single operation can have several independent failures, like validating a cart where multiple items are unavailable.
errors.Is and errors.As know how to walk these trees. They don't just descend a single chain; at each node, they look at every wrapped error and recurse. The walk is depth-first, and the first match wins.
errors.Join returns a single error value whose Unwrap method returns []error rather than a single error. errors.Is checks against each child in turn, so the same call answers about every member of the joined group. This is how a caller can write one if errors.Is(err, ErrOutOfStock) and have it work whether the cart had one item out of stock or three.
errors.As works the same way on a joined tree. It walks every branch looking for the first link that matches the target type:
The joined error has two children. The first is a plain string error, which doesn't match *strconv.NumError. The second wraps a *strconv.NumError, and errors.As finds it by descending into that branch. The same logic extends to joined errors that contain other joined errors; the walk recursively flattens the tree.
The diagram shows the tree shape produced by errors.Join with two wrapped children. errors.Is(joined, ErrOutOfStock) descends into the left subtree and finds a match. errors.Is(joined, ErrBadCoupon) descends into the right subtree and finds a match too. The standard library handles the recursion for you; you just call errors.Is once per target you care about.
Cost: walking a joined tree is proportional to the total number of nodes across all branches. For the small joined errors you'll typically build (a handful of validation failures, maybe), this is still cheap. Joining hundreds of errors is unusual and would be the place to think about cost.