---
title: 'Error Wrapping and Unwrapping'
source: 'https://academia.sh/en/courses/go-type-system/error-wrapping-and-unwrapping'
course: 'Interfaces, Errors and Generics'
language: en
updated: '2026-09-14T10:59:34+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures that a sentinel error can still be found with errors.Is in a three-layer chain, and that the chain breaks when %v is used'
    - 'Follows the chain by hand, step by step, with errors.Unwrap and shows by running it the path errors.Is walks'
    - 'Proves extracting a specific target type from the chain with errors.As, and that the wrong context does not break identity'
---

# Error Wrapping and Unwrapping

An error wrapped with %w stands as a chain inside the outer error, and errors.Is finds the sentinel by walking this chain layer by layer with Unwrap; when %v is used, even though the same text is produced, the chain breaks and errors.Is can no longer find it. errors.As extracts a specific target type from the chain. The limiting measure: even when the wrapping context's text is wrong, the chain's identity is not broken, and no warning is produced.

The previous lesson measured how a sentinel error's identity is preserved with `==` or with
an `Is` method — but always in single-layer calls. Real call chains usually pass through
several layers, and each layer wants to add its own context: something like "this error
occurred while performing that operation." This lesson measures how this context gets added,
and how many layers down the sentinel at the bottom of the chain can still be found.

Wrapping also offers a new observation for the topic's measure. The previous lesson measured
how an error's identity is shared; but as a call chain grows, the outer error is no longer the
sentinel **itself**, it becomes another value containing it. A comparison like
`h1 == ErrMissing` does not work in such a chain — neither does the outer value carry the same
address as the sentinel, nor does its text stay the same. This lesson shows how identity can
still be queried once it is embedded in a chain.

## The Bottom of the Chain: Identity Carried With `%w`

When `fmt.Errorf` is called with the `%w` formatting verb, it produces a new error, and this
new error holds the given error **inside** itself. The three-layer chain below builds this in
two forms: the `Sound` version uses `%w` at every layer, the `Broken` version uses `%v` at the
middle layer — both produce the same text.

Three layers were chosen deliberately here: in a real codebase, a storage layer, a service
layer calling it, and a handler layer calling that service build a similar chain. Each layer
adds a sentence from its own point of view (not "record not found" but something like "user
could not be authenticated: record not found"), but the question of what the bottommost cause
is does not change. The measurement tests whether the answer to this question is **preserved**
throughout the chain.

```go
// binding.go — is the error binding established at the use site, or inside the value
package main

import "fmt"

var useSite, value int

func observe(name, location, evidence string) {
	switch location {
	case "use":
		useSite++
	default:
		value++
	}
	fmt.Printf("%-50s %-9s %s\n", name, location, evidence)
}

func summary() {
	fmt.Printf("binding: %d use site, %d value\n", useSite, value)
}
```

```go
// chain.go — three-layer wrapping: the sentinel error is found even at the bottom of the chain, %v cuts the chain
package main

import (
	"errors"
	"fmt"
)

var ErrMissing = errors.New("no record")

func layer1() error {
	return ErrMissing
}

func layer2Sound() error {
	if err := layer1(); err != nil {
		return fmt.Errorf("layer2 failed: %w", err)
	}
	return nil
}

func layer3Sound() error {
	if err := layer2Sound(); err != nil {
		return fmt.Errorf("layer3 failed: %w", err)
	}
	return nil
}

func layer2Broken() error {
	if err := layer1(); err != nil {
		return fmt.Errorf("layer2 failed: %v", err)
	}
	return nil
}

func layer3Broken() error {
	if err := layer2Broken(); err != nil {
		return fmt.Errorf("layer3 failed: %w", err)
	}
	return nil
}

func main() {
	sound := layer3Sound()
	broken := layer3Broken()

	observe("sound chain's outer text", "value", sound.Error())
	observe("broken chain's outer text", "value", broken.Error())
	observe("is the sentinel found in the sound chain", "value", fmt.Sprint(errors.Is(sound, ErrMissing)))
	observe("is the sentinel found in the broken chain", "value", fmt.Sprint(errors.Is(broken, ErrMissing)))
	summary()
}
```

```
sound chain's outer text                           value     layer3 failed: layer2 failed: no record
broken chain's outer text                          value     layer3 failed: layer2 failed: no record
is the sentinel found in the sound chain           value     true
is the sentinel found in the broken chain          value     false
binding: 0 use site, 4 value
```

**EH13.** The first two lines are one of the course's clearest demonstrations: the two
chains' **text is exactly the same**, because both `%w` and `%v` place the same
`Error() string` output inside the new text `fmt.Errorf` produces. Someone looking at the text
cannot tell the two errors apart. But the third and fourth lines give an entirely different
result: `errors.Is(sound, ErrMissing)` is `true`, `errors.Is(broken, ErrMissing)` is `false`.
The text stays the same while the query result changes — because the question `errors.Is`
asks looks not at the text, but at the chain.

**EH14.** The difference is in a single behavior between `%w` and `%v`: `%w` gives the new
error `fmt.Errorf` produces an `Unwrap() error` method, and this method returns the given
error; `%v` only embeds the text, it adds no method. The error `layer2Broken` produces has no
`Unwrap` — the chain cuts off right there, without reaching `ErrMissing`. `layer3Broken` using
its own `%w` does not compensate for this break, because the place it broke is already one
layer below.

This shows that error wrapping is a **one-directional** chain: a layer above cannot fix a
layer below it breaking the chain. `layer3Broken` wraps the error `layer2Broken` produces with
`%w`, and correctly builds its own Unwrap method — but the value it wraps **itself** already
carries no link to `ErrMissing` at the bottom. The chain's soundness depends on its weakest
layer; a single use of `%v` makes every layer above it behaving correctly meaningless.

## Walking Inside the Chain: `errors.Unwrap`

What `errors.Is` does is actually built on a single primitive: `errors.Unwrap`. This function
takes an error and returns one layer below it if that error has written an
`Unwrap() error` method; it returns `nil` if it has not. `errors.Is` calls this in a loop
until it finds the target or reaches `nil`. The measurement below builds this loop by hand.

This shows that `errors.Is` is not a "magic" behavior, it is built on an ordinary loop and an
ordinary method call. The chain can be built because `%w` gives an error the `Unwrap` method;
without this method, `errors.Unwrap` would return `nil` on the first call, and the bottom of
the chain could never be reached. The error `layer2Broken` produces in the previous section is
exactly in this situation: because it was produced with `%v`, it has no `Unwrap` method;
`errors.Unwrap` returns `nil` when called on it.

```go
// stepbystep.go — manually walking, step by step with Unwrap, the same chain errors.Is walks
package main

import (
	"errors"
	"fmt"
)

var ErrMissing = errors.New("no record")

func main() {
	wrapped := fmt.Errorf("outer layer: %w", fmt.Errorf("middle layer: %w", ErrMissing))

	step := 0
	current := wrapped
	for current != nil {
		observe(fmt.Sprintf("chain step %d", step), "value", current.Error())
		current = errors.Unwrap(current)
		step++
	}
	observe("does errors.Is find the same chain in one call", "value", fmt.Sprint(errors.Is(wrapped, ErrMissing)))
	summary()
}
```

```
chain step 0                                       value     outer layer: middle layer: no record
chain step 1                                       value     middle layer: no record
chain step 2                                       value     no record
does errors.Is find the same chain in one call     value     true
binding: 0 use site, 4 value
```

**EH15.** The loop stops at three steps: `current` carries, in turn, the outer error, the
middle error, and `ErrMissing` at the very bottom; on the fourth call,
`errors.Unwrap(ErrMissing)` returns `nil` because `ErrMissing` was produced with `errors.New`
and has no `Unwrap` method — the loop stops there. Each step's text is the previous one's
**final piece**; this shows that the chain really stands nested, carried not as a single long
string but as separate, linked error values. `errors.Is(wrapped, ErrMissing)` does these three
steps in a single call and returns `true` — the same mechanism as the `sound` chain in the
previous section.

This step-by-step walk also shows `errors.Is`'s cost: the longer the chain, the more times
`errors.Unwrap` gets called. In practice, this cost stays at a negligible level in a call
chain a few layers deep — but the chain not entering an infinite loop also depends on
`Unwrap` genuinely returning a **different** value at every step. An error wrapping itself
(`Unwrap` returning the same value) is not measured here, but it explains why the chain was
designed as a structure that always **advances**, never turning back.

## Extracting a Type From the Chain: `errors.As`

`errors.Is` asks whether a target **value** is in the chain; `errors.As`, on the other hand,
asks whether there is an error of a specific **type** in the chain, and if there is, writes it
into a variable. This shows that the chain preserves not only identity, but also the **data**
it carries.

This distinction also overlaps with the previous lesson's two forms of identity. For a
sentinel carrying no data, like `ErrMissing`, the question is always "is this the value" —
`errors.Is` is enough. But for an error kind with its own field, like `FieldError`, the real
question is usually "is this an error of this kind, and what is its field" — `errors.As`
answers this question. This is also why `errors.As`'s second argument takes a pointer to a
pointer: it needs to be able to write the value it finds directly into the caller's variable.

```go
// target.go — extracting a target type from the chain; the wrong context does not break the chain's identity
package main

import (
	"errors"
	"fmt"
)

var ErrMissing = errors.New("no record")

type FieldError struct{ Field string }

func (a *FieldError) Error() string { return "invalid field: " + a.Field }

func validate() error {
	return fmt.Errorf("record could not be validated: %w", &FieldError{Field: "name"})
}

func layer1() error {
	return ErrMissing
}

func layerWrongContext() error {
	if err := layer1(); err != nil {
		return fmt.Errorf("unexpected network error: %w", err)
	}
	return nil
}

func main() {
	var target *FieldError
	found := errors.As(validate(), &target)
	observe("extracting the FieldError type from the chain", "value", fmt.Sprint(found, " ", target.Field))

	wrong := layerWrongContext()
	observe("text of the error wrapped with the wrong context", "value", wrong.Error())
	observe("is identity still found despite the wrong context", "value", fmt.Sprint(errors.Is(wrong, ErrMissing)))
	summary()
}
```

```
extracting the FieldError type from the chain      value     true name
text of the error wrapped with the wrong context   value     unexpected network error: no record
is identity still found despite the wrong context  value     true
binding: 0 use site, 3 value
```

**EH16.** `validate` wraps a `*FieldError` at the top of the chain.
`errors.As(validate(), &target)` walks the chain with `errors.Unwrap`, looks for an error of
type `*FieldError`, finds it, and **assigns** it to `target` — afterward, `target.Field` can
be read directly. This is something `errors.Is` cannot give: `errors.Is` only asks "does this
value exist in the chain"; `errors.As` says "does a value of this type exist, and if so, give
it to me." Both walk the same `Unwrap` chain, asking a different question.

The `validate` function's return type is also worth noting here: its signature says only
`error`, not `*FieldError` — doing the reverse of the trap measured in the Error Interface
lesson. The concrete type does not appear in the signature, it is embedded only inside the
chain; if the caller wants to know it, it has to query the chain with `errors.As`. The
signature itself makes no promise at all about which types stand inside the chain.

**EH17.** The second measurement builds this lesson's limiting observation.
`layerWrongContext` writes "unexpected network error" into the text even though the real
cause is `ErrMissing` — the context is wrong, even misleading. But
`errors.Is(wrong, ErrMissing)` still returns `true`: the chain is still sound, because `%w`
was used and the `Unwrap` method still reaches `ErrMissing`. The chain preserves **identity**,
but never checks the **correctness** of the wrapping text — writing a wrong context produces
no warning at either compile time or run time, because `fmt.Errorf`'s first argument is an
ordinary string, and the language has no check that looks at it.

The practical consequence of this observation is clear: a check written with `errors.Is` and
`errors.As` keeps working correctly without looking at what the wrapping text says — this is
an advantage, because the text can be changed freely for human reading; identity checking is
not affected by this. But the same property also carries a risk: when a developer adds a
wrong or misleading context to the chain, the only way to notice it is to **read** the text;
neither `errors.Is` nor `errors.As` gives a warning, because both look only at identity and
type.

These three measurements connect to the topic's common measure in the same way: a sentinel
error's identity (previous lesson), the text an error wrapping carries, and extracting a type
from a wrapped chain — all three are carried **inside the value**, appearing with no
declaration in the source. This is the error-chain counterpart of the common measurement
where eight of the sixteen bindings are carried in the value.

The picture these three lessons (The Error Interface, Sentinel Errors, Error Wrapping and
Unwrapping) build together is this: `error` is implicitly satisfied as an interface, and the
satisfying type is never mentioned in the source (use site); an error's identity rests on an
address or on an `Is` method (value); and this identity, even when carried wrapped between
layers, is not lost, thanks to the `Unwrap` chain (value again). Someone reading the source
cannot look at either a declaration or a signature to see which sentinel stands at the bottom
of a three-layer chain — it is enough only to actually walk the chain (or to have `errors.Is`
do that walking).

## Summary

- `%w` adds an `Unwrap() error` method to the error `fmt.Errorf` produces; `%v` does
  not — even if the text stays the same, the chain breaks at the layer where `%v` is used.
- `errors.Is` calls `errors.Unwrap` in a loop until it finds the target or reaches `nil`; if
  the chain has three layers, it is walked in three steps.
- `errors.As` looks for an error of a specific type in the chain and, when it finds one,
  writes it into a variable; it carries data `errors.Is` cannot give (such as a field like
  `Field`) this way.
- The chain's identity (`errors.Is`, `errors.As`) is preserved, but the wrapping text's
  correctness is never checked — writing a wrong context produces no warning.
- A sentinel error's identity, the text an error wrapping carries, and extracting a type from
  the chain are all three carried inside the value; no declaration in the source shows this
  binding.
- `errors.Unwrap` not entering an infinite loop depends on every layer carrying a different
  value beneath it; the chain always advances, it never turns back on itself.

## Next Step

Every layer measured in this lesson **returned** an `error` by wrapping its own context — the
return type always stayed the same, the signature never changed. No matter how long the chain
got, every function's signature still had a single contract: the last value is an `error`.
But in Go, there is also a way for a function to fail that never appears in the signature at
all: panic. When a function calls `panic`, no trace remains in the return type — there is no
difference in signature between `func F() int` and `func F() int`, one of them always returns
an integer while the other can crash at run time. The next lesson measures this exit: if a
function panics instead of returning an error, where does this choice appear in the
signature, and how can the caller know it in advance?
