Errors in Go: the one letter that breaks the chain, and the type assertion that will stop working
The interview climbs a ladder: what error is — how %w differs from %v — why == stops working after a wrap — how Is differs from As — what Join does — and what it all costs. Measured: errors.Is over a chain of twenty wrappers is 8.2 times dearer, and a wrap itself is two allocations and 162 ns, paid only on the error path.
Full technical treatment
TL;DR
An error in Go is an ordinary value that is handed back out. There is no
separate failure mechanism jumping over the stack: a function returns the error
as its last result, the caller either deals with it or adds context and passes
it further up, and somebody at the top decides what to do about it. error is
an ordinary interface with one method; everything else is conventions and
the errors package.
The main consequence: everything depends on whether the way back to the
original error survived. %w keeps a reference to it, %v does not — one
letter's difference, the same message in the log, and errors.Is gives true
and false respectively. For the same reason == does not unwrap: after
fmt.Errorf("...: %w", err) a comparison with the original gives false. And
a type assertion looks only at the top level: on the very same error
errors.As gives true while err.(*NotFoundError) gives false, so such
code works right until somebody else's edit adds a wrapper one level up.
Wrapping, meanwhile, is a commitment: a wrapped error becomes part of your
package's API, and people will start checking for it.
Beyond that come the edge cases and the price. errors.Join is a tree, not
a wrapper: Is finds either of the joined errors, while the single-result
Unwrap is undefined for it and returns nil. A wrap costs 162 ns and two
allocations, but it is paid only on the error path; a check is always paid and
grows with depth — errors.Is over a chain of twenty wrappers is 8.2 times
dearer than over one. The numbers come from one run on one machine.
- a function can fail, and the caller needs to learn about it;
- in Go a function returns several values, and the last of them is usually the sign of failure;
- an interface is a requirement on behaviour: a type fits if it has the required method.
- what wrapping an error is, what a chain is, what unwrapping means;
%w,errors.Is,errors.As,errors.Join, sentinel errors, the typednil.
What is really being asked
The ladder is almost always this one:
- "What is
errorin Go?" — the warm-up: an interface with one method. - "How does
%wdiffer from%v?" — the first half drops out here. - "Why did
err == ErrNotFoundstop working?" — about wrapping. - "
IsorAs?" — and why a type assertion is worse than both. - "What is a sentinel error and when should you not have one?" — about commitments.
- "What does it cost?" — a question about honesty.
The lesson climbs that ladder. Its spine is one sentence: a wrap is a reference downwards, and every behaviour depends on whether it is there.
Base: an error is a value that gets passed upwards
Go has no separate failure mechanism: a failure is reported with an ordinary value. A function that may not succeed returns one as its last result, and everything after that is done by hand — which is why the whole flow is visible.
It looks like this, and there are no other participants in it:
- a function hits a failure and returns an error to its caller;
- the caller checks it and decides: handle it here, or pass it further up;
- if it passes it on, it adds context — where it was and what it was doing — and returns the result upwards;
- somebody at the top makes the decision: answer the client, retry, write
a log line. Exactly one place decides, usually a request handler, a worker or
main.
The third step is the key one. The error travels upwards and gathers context on
the way: open /etc/app.yaml: no such file becomes
load config: open /etc/app.yaml: no such file, then serve: load config: ….
In the log that reads as a single path — from the place that decides to the
place that failed.
And here is the question this lesson is about: what happens to the original error when context is added to it? At the top you do not merely print the text, you work out what happened: "the file is missing" calls for one decision, "permission denied" for another. So adding context has to preserve whatever the top will tell those apart by.
The answer: the new error may keep a reference to the old one, or it may not. Either way the log line comes out the same — and that single difference is what the rest of the topic grows from.
That is already enough to answer the basic interview question: an error is a value, it is returned, context is added to it on the way up, and at the top the cause is classified. Everything below is about what adds the context, how the cause is classified, what happens when there are several failures at once, and what all of it costs.
Mechanism 1: the three jobs error does
Working with errors has exactly three jobs, and nearly all the confusion comes from not separating them:
| job | how it is done |
|---|---|
| return an error to the caller | a type with an Error() string method |
| add context without losing the cause | fmt.Errorf with %w |
| classify the cause at the caller | errors.Is and errors.As |
Each row rests on the one above: you can only classify what was not lost while context was being added. The lesson follows those three jobs.
Start with the first, and it is the shortest of all:
type error interface {
Error() string
}That is all. Three consequences follow at once, and they are worth naming:
- An error is an ordinary value. It can be stored in a variable, compared, passed and returned — it is no different from any other value.
- Any type with an
Error() stringmethod is an error. No registration required. erroris an interface, so the typed-nil trap applies to it. A function declared as returningerrorbut returning a variable of a concrete type will return an "error" even when there is none.
There is a formatting convention checked in review:
Error strings should not be capitalized (unless beginning with proper nouns or
acronyms) or end with punctuation, since they are usually printed following
other context.
Hence the shape of a wrap: fmt.Errorf("load config: %w", err) — a verb and a
colon, not a complete sentence. It reads bottom-up as one path:
serve: load config: open /etc/app.yaml: no such file.
Mechanism 2: one letter decides everything
Two lines differing by one character:
fmt.Errorf("ctx: %w", err) // the chain is kept
fmt.Errorf("ctx: %v", err) // the chain is brokenTheir message in the log is the same. The only difference is whether the new error holds a reference to the old one:
If the format specifier includes a %w verb with an error operand, the returned
error will implement an Unwrap method returning the operand.
Switch the cases — you can see whether the top error holds a reference downwards:
Verified by the run:
errors.Is(err, base) | errors.Unwrap(err) | |
|---|---|---|
%w | true | base |
%v | false | nil |
And the third line of the same run: wrapped() == errNotFound gives false.
== looks at the value itself, and after wrapping that is a different value.
Hence the rule: sentinel errors are compared with errors.Is, not == —
otherwise the code breaks on somebody else's edit that added a wrapper two
levels below.
Mechanism 3: Is and As are a walk along the chain, not magic
Before looking at signatures it is worth saying what these functions do — otherwise they get memorised as two magic checks that "somehow know".
Both do one thing: they walk the Unwrap chain from the outer error to the
inner one and ask their own question at each step.
errors.Is asks "is this that very value?", errors.As asks "is this that very
type?". Everything else is detail: both stop at the first "yes", both return
false on reaching the end of the chain.
Which makes it immediately clear why the %v from the previous section breaks
both: without Unwrap there is no chain, and nothing to walk — the very first
step hits nil.
The difference between Is and As is simple once you remember what each
searches for.
errors.Is searches for a specific VALUE — usually a sentinel such as
os.ErrNotExist:
Is unwraps its first argument sequentially looking for an error that matches the
second.
errors.As searches for a specific TYPE and extracts the value along the
way, so the fields can be read:
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
log.Println(pathErr.Path) // this is what As is for
}And worse than both is a type assertion. It looks only at the top level:
| result | |
|---|---|
errors.As(err, &target) | true |
err.(*NotFoundError) | false |
This is the same error. The difference is that two wrappers ended up between it and the type at the base. The dangerous part is not the fact but how it shows up: code using a type assertion works right up to the first edit that adds a wrapper one level higher — and then stops working silently, with no build error and no failing test.
Hence the rule: in error handling a type assertion is not used at all. There
is errors.As, and it does the same thing over the whole chain.
Mechanism 4: Join is a tree, not a wrapper
errors.Join arrived in Go 1.20 and is the thing most often confused with
wrapping.
err := errors.Join(errA, errB)
errors.Is(err, errA) // true
errors.Is(err, errB) // true
errors.Unwrap(err) // nil ← this is where people get it wrongThe reason is that Join implements Unwrap() []error, not
Unwrap() error. The single-result errors.Unwrap is undefined for it and
honestly returns nil. Such an error is unwrapped through Is/As, which walk
the tree.
The same, incidentally, applies to fmt.Errorf with several %w verbs: the
documentation says outright that several verbs produce an Unwrap() []error.
Where Join belongs. Where there really are several errors and all are
equal: validating a configuration, parsing a form, closing several resources.
Where there is one error and context is needed, that is %w, not Join.
Mechanism 5: wrapping is taking on a commitment
The least obvious answer in this topic, and one worth being able to give.
Whether to wrap an error is a decision about whether to expose the underlying
error to the caller. Wrapping an error makes it part of your API.
Read it as: once you wrap sql.ErrNoRows with %w, the caller may write
errors.Is(err, sql.ErrNoRows) — and they will. Changing the database or moving
to another driver then breaks their code even though your signature never
changed.
If you don't want to commit to supporting the error as part of your API in the
future, you shouldn't wrap the error.
The practical rule comes out as: inside a package wrap with %w freely; at
the package boundary decide deliberately. What usually goes out is your own
sentinel errors (repo.ErrNotFound), with somebody else's translated into
them — so the internal machinery does not become a commitment.
What to choose: a decision table
Four ways to report a cause, and the choice between them is not a matter of taste:
| you need | use | the price |
|---|---|---|
| the caller to recognise one known case | a sentinel: var ErrNotFound = errors.New(…) | the value becomes part of the API forever |
| the caller to have details of the cause | a custom type + errors.As | the type and its fields become part of the API |
| to add context while keeping the cause | fmt.Errorf("…: %w", err) | the cause becomes an observable part of the contract |
| to report several failures at once | errors.Join | a tree rather than a chain; Is walks every branch |
| the caller not to see the cause | fmt.Errorf("…: %v", err) | there is no chain — and that is a deliberate choice, not a typo |
The last row is the one legitimate case for %v, and it explains why both forms
are in the language. %v means "the text of the cause is wanted in the log, a
programmatic check is not". The problem is not the form itself but that it gets
written by accident: in the log the two look identical.
Deeper: what it costs
The numbers come from one run of bench/goerrors/internals.go on one machine:
your nanoseconds will be your own, the relations between them the same.
A wrap costs noticeably, but only on the error path:
| time | allocations | |
|---|---|---|
| return a ready error | 0.65 ns | 0 |
errors.New on every call | 29.11 ns | |
fmt.Errorf with %w | 162.02 ns | 2 |
A hundred and sixty nanoseconds looks like a lot right until you say when they are paid: only once the error has already happened. On the success path there are no errors, so there is no such cost.
The second row is a separate practical point: errors.New inside a function
creates a new error on every call. Sentinels are declared once at package
level, and not only for those thirty nanoseconds but so they can be compared
against.
The check, though, is always paid, and it grows with depth:
| chain depth | errors.Is | |
|---|---|---|
| 1 | 12.79 ns | ×1.0 |
| 5 | 32.80 | ×2.6 |
| 10 | 55.45 | ×4.3 |
| 20 | 105.32 | ×8.2 |
errors.Is removes wrappers one at a time, so the cost is nearly linear in
depth. Hence the practical part: wrap where meaning is added, not at every
level in a row. Three wrappers reading a: b: c: the real cause are useful;
fifteen are noise in the log and eight extra links on every check.
How to answer in an interview
Short answer: an error in Go is an ordinary value, returned and carried upwards by hand. The function returned it, the caller added context and passed it on, and one place at the top made the decision. Everything else in the topic is about whether adding that context kept a reference to the original error: with it the cause can be classified at the top, without it only the log text remains.
That is enough for a correct answer. What follows is what you add when the interviewer digs.
If the interviewer digs deeper
To "what is error" answer with the one-line definition. "An interface with
an Error() string method; hence the typed-nil trap applies to it too."
On %w and %v, talk about the reference rather than "wrapping". "%w
keeps a reference to the original, %v inserts its text; the message is the
same and errors.Is gives true and false."
On ==, name the consequence. "After wrapping it is a different value, so
comparison goes through errors.Is — otherwise the code breaks on somebody
else's edit two levels below."
On Is and As, say what each searches for. "Is a value, As a type,
and As extracts the fields too. A type assertion is not used at all: it looks
only at the top level and silently stops working after the first wrapper."
On Join say "tree". "It implements Unwrap() []error, so the
single-result Unwrap returns nil and unwrapping goes through Is/As."
On cost, separate the two numbers. "A wrap is hundreds of nanoseconds and
two allocations, but only on the error path. A check is always paid and grows
with depth: at twenty wrappers errors.Is is eight times dearer."
Next they ask
When do you make a sentinel error and when your own type?
A sentinel is for when the caller only needs the fact: "not found", "no
access", "already exists". One value at package level, compared with
errors.Is.
Your own type is for when details are needed: which field failed validation,
which path would not open, how many retries remain. Then errors.As extracts
the value and the fields can be read. The guide is simple: if the caller will
write if errors.Is(...) and stop there, a sentinel is enough; if they will
reach for data after the check, a type is needed.
How do you implement Is or As for your own type?
An Is(error) bool or As(any) bool method on your type — errors.Is and
errors.As call them while walking the chain.
This is rarely needed, and in one characteristic case: when errors are "the same
in meaning" but not in value. For example, an HTTPError{Code: 404} may
consider itself equal to the ErrNotFound sentinel — then the caller need know
nothing about codes. Without such a method errors.Is compares values with a
plain ==.
What is wrong with github.com/pkg/errors today?
Nothing fatal, but it is no longer needed for the main thing: wrapping and
unwrapping moved into the standard library in Go 1.13. Its errors.Wrap and
Cause solved exactly what %w and errors.Is solve now.
One thing it gave beyond that was a call stack captured when the error was created, and the standard library still has no such thing. If a stack is needed it is added with your own error type; migrating a whole project to a third-party library just for that is not the done thing today.
How do you log an error without logging it three times?
The rule is simple: either handle it or return it — not both. The most common trouble in logs is one error written at every level of the stack, because everyone "just in case" both logged and returned it.
Whoever makes the decision logs: the HTTP handler, the worker, main. All
the intermediate levels only add context with %w and pass it on. Then there is
one record in the log, and it contains the whole path.
What does errors.Is do with nil?
errors.Is(nil, nil) is true, and errors.Is(nil, target) with a non-nil
target is false. There are no special traps there, but there is an adjacent
one: if a function returned a typed nil inside the error interface, then
err != nil is true while errors.Is(err, ErrX) gives false — and the code
takes the "unrecognised error" branch.
Hence the practical part: in error handling the typed-nil trap shows up not as a panic but as an error that cannot be classified.
How many levels of wrapping is normal?
As many as add meaning. The guide: a wrap is useful if its text tells the
caller something new about where and why the error happened. open config: %w
is useful; error: %w is not.
The measurement gives the other side too: at twenty wrappers errors.Is is 8.2
times dearer than at one. That is not a ban but a reminder that every extra link
is paid on every check, and there are usually more checks than wrappers.
Common misconceptions
%w and %v do the same thing, %w is just prettier
%w keeps a reference to the original error, %v inserts its text. The log message is identical while errors.Is gives true and false. One letter and the chain is gone — and it is invisible in review.
a sentinel error can be compared with ==
Only until somebody wraps it. After fmt.Errorf("...: %w", err) it is a different value and == gives false. Which is why comparison goes through errors.Is — it unwraps the chain.
a type assertion and errors.As are the same thing
A type assertion looks only at the top level: on the very same error errors.As gives true while err.(*NotFoundError) gives false. And it breaks silently: the code works until somebody adds a wrapper one level up.
errors.Unwrap will unwrap any error
Not two of them: one made with %v (no reference) and one made with errors.Join or several %w verbs — there it is Unwrap() []error, a tree rather than a list. In both cases the single-result Unwrap honestly returns nil.
errors.Join is a way to wrap an error with context
It is a way to combine several equal errors into one: validating a configuration, parsing a form, closing several resources. For "one error, context needed" there is %w.
wrapping is expensive, better return the error as is
A wrap is 162 ns and two allocations, but it is paid only on the error path: on the success path there are no errors at all. What is dearer is the check: errors.Is at twenty wrappers is 8.2 times dearer than at one, and that is paid always.
errors.New can be called anywhere
It can, but errors.New inside a function creates a new error on every call — 29 ns and, more importantly, a value nothing can be compared against. That is exactly why sentinels are declared once at package level.
wrapping everything with %w is always good
A wrap makes the error part of your package's API: the caller will start checking it with errors.Is, and swapping a library inside will break their code. The Go blog says it outright: if you are not ready to support it, do not wrap. At a package boundary somebody else's errors are translated into your own.
Practice
Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.
Practice · predict the output
fmt.Println(errors.Is(wrapped(), errNotFound)) fmt.Println(errors.Is(formatted(), errNotFound)) fmt.Println(wrapped() == errNotFound) var target *NotFoundError fmt.Println(errors.As(typedWrapped(), &target)) _, ok := typedWrapped().(*NotFoundError) fmt.Println(ok) fmt.Println(errors.Unwrap(typedWrapped()))
Practice · estimate
Knowledge check
A function returned fmt.Errorf("ctx: %v", ErrNotFound). What does errors.Is(err, ErrNotFound) give the caller?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- An error in Go is an ordinary value that is handed back out. There is no separate failure mechanism jumping over the stack: a function returns the error as its last result, the caller either deals with it or adds context and passes it further up, and somebody at the top decides what to do about it.
erroris an ordinary interface with one method; everything else is conventions and theerrorspackage. - The main consequence: everything depends on whether the way back to the original error survived.
%wkeeps a reference to it,%vdoes not — one letter's difference, the same message in the log, anderrors.Isgives true and false respectively. For the same reason==does not unwrap: afterfmt.Errorf("...: %w", err)a comparison with the original gives false. And a type assertion looks only at the top level: on the very same errorerrors.Asgives true whileerr.(*NotFoundError)gives false, so such code works right until somebody else's edit adds a wrapper one level up. Wrapping, meanwhile, is a commitment: a wrapped error becomes part of your package's API, and people will start checking for it. - Beyond that come the edge cases and the price.
errors.Joinis a tree, not a wrapper:Isfinds either of the joined errors, while the single-resultUnwrapis undefined for it and returns nil. A wrap costs 162 ns and two allocations, but it is paid only on the error path; a check is always paid and grows with depth —errors.Isover a chain of twenty wrappers is 8.2 times dearer than over one. The numbers come from one run on one machine.
In fact
%wkeeps a reference to the original error,%vinserts its text. The log message is identical whileerrors.Isgives true and false. One letter and the chain is gone — and it is invisible in review.- Only until somebody wraps it. After
fmt.Errorf("...: %w", err)it is a different value and==gives false. Which is why comparison goes througherrors.Is— it unwraps the chain. - A type assertion looks only at the top level: on the very same error
errors.Asgives true whileerr.(*NotFoundError)gives false. And it breaks silently: the code works until somebody adds a wrapper one level up. - Not two of them: one made with
%v(no reference) and one made witherrors.Joinor several%wverbs — there it isUnwrap() []error, a tree rather than a list. In both cases the single-resultUnwraphonestly returnsnil. - It is a way to combine several equal errors into one: validating a configuration, parsing a form, closing several resources. For "one error, context needed" there is
%w. - A wrap is 162 ns and two allocations, but it is paid only on the error path: on the success path there are no errors at all. What is dearer is the check:
errors.Isat twenty wrappers is 8.2 times dearer than at one, and that is paid always. - It can, but
errors.Newinside a function creates a new error on every call — 29 ns and, more importantly, a value nothing can be compared against. That is exactly why sentinels are declared once at package level. - A wrap makes the error part of your package's API: the caller will start checking it with
errors.Is, and swapping a library inside will break their code. The Go blog says it outright: if you are not ready to support it, do not wrap. At a package boundary somebody else's errors are translated into your own.
What is covered
- What is really being asked
- Base: an error is a value that gets passed upwards
- Mechanism 1: the three jobs error does
- Mechanism 2: one letter decides everything
- Mechanism 3: Is and As are a walk along the chain, not magic
- Mechanism 4: Join is a tree, not a wrapper
- Mechanism 5: wrapping is taking on a commitment
- Deeper: what it costs
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
4 SOURCES
- The errors package — Is, As, Unwrap, JoinOfficial documentation. The rules the whole topic derives from. On Is: «Is unwraps its first argument sequentially looking for an error that matches the second». On As: «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». On Join: «Join returns an error that wraps the given errors. Any nil error values are discarded. The error formats as the concatenation of the strings obtained by calling the Error method of each element of errs».https://pkg.go.dev/errors
- The fmt package — the %w verbOfficial documentation. Where the only difference between %w and %v is defined: «If the format specifier includes a %w verb with an error operand, the returned error will implement an Unwrap method returning the operand. If there is more than one %w verb, the returned error will implement an Unwrap method returning a []error containing all the %w operands in the order they appear in the arguments».https://pkg.go.dev/fmt#Errorf
- Working with Errors in Go 1.13 — the Go blogOfficial documentation. The authors' own advice on when to wrap and when not to: «Whether to wrap an error is a decision about whether to expose the underlying error to the caller. Wrapping an error makes it part of your API». And on compatibility outright: «If you don't want to commit to supporting the error as part of your API in the future, you shouldn't wrap the error».https://go.dev/blog/go1.13-errors
- Go Code Review Comments — error stringsOfficial documentation. The formatting convention checked in review: «Error strings should not be capitalized (unless beginning with proper nouns or acronyms) or end with punctuation, since they are usually printed following other context». Hence a wrap is written as «verb: %w» rather than as a complete sentence.https://go.dev/wiki/CodeReviewComments#error-strings