Lesson 08 / 13
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.
Published Updated
In this lesson
- 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
Contents
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.
// 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)
}
// 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.
// 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.
// 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
%wadds anUnwrap() errormethod to the errorfmt.Errorfproduces;%vdoes not — even if the text stays the same, the chain breaks at the layer where%vis used.errors.Iscallserrors.Unwrapin a loop until it finds the target or reachesnil; if the chain has three layers, it is walked in three steps.errors.Aslooks for an error of a specific type in the chain and, when it finds one, writes it into a variable; it carries dataerrors.Iscannot give (such as a field likeField) 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.Unwrapnot 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?
To keep your progress and take notes, Log in
My notes
Log in to take notes.