Deep Engineering
Intermediate·Published·25 MIN

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.

Where to start
Before this lesson it is enough to understand
  • 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.
You do not need to know in advance
  • what wrapping an error is, what a chain is, what unwrapping means;
  • %w, errors.Is, errors.As, errors.Join, sentinel errors, the typed nil.

What is really being asked

The ladder is almost always this one:

  1. "What is error in Go?" — the warm-up: an interface with one method.
  2. "How does %w differ from %v?" — the first half drops out here.
  3. "Why did err == ErrNotFound stop working?" — about wrapping.
  4. "Is or As?" — and why a type assertion is worse than both.
  5. "What is a sentinel error and when should you not have one?" — about commitments.
  6. "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:

  1. a function hits a failure and returns an error to its caller;
  2. the caller checks it and decides: handle it here, or pass it further up;
  3. if it passes it on, it adds context — where it was and what it was doing — and returns the result upwards;
  4. 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:

jobhow it is done
return an error to the callera type with an Error() string method
add context without losing the causefmt.Errorf with %w
classify the cause at the callererrors.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.

language contractGuarantees of the language and the standard library. Numbers and cost are a separate matter: they come from measurements rather than from documentation.

Start with the first, and it is the shortest of all:

GO
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() string method is an error. No registration required.
  • error is an interface, so the typed-nil trap applies to it. A function declared as returning error but 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.

Go Code Review Comments

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:

GO
fmt.Errorf("ctx: %w", err)   // the chain is kept
fmt.Errorf("ctx: %v", err)   // the chain is broken

Their 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.

The fmt package — Errorf

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)
%wtruebase
%vfalsenil

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.

The errors package — Is

errors.As searches for a specific TYPE and extracts the value along the way, so the fields can be read:

GO
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.

GO
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 wrong

The 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.

Working with Errors in Go 1.13

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.

Working with Errors in Go 1.13

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 needusethe price
the caller to recognise one known casea sentinel: var ErrNotFound = errors.New(…)the value becomes part of the API forever
the caller to have details of the causea custom type + errors.Asthe type and its fields become part of the API
to add context while keeping the causefmt.Errorf("…: %w", err)the cause becomes an observable part of the contract
to report several failures at onceerrors.Joina tree rather than a chain; Is walks every branch
the caller not to see the causefmt.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

measured observationbench/goerrors, go1.24.7 linux/amd64, two cores. The numbers were taken on one machine; what carries meaning is the ratio and what is missing from it.

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:

timeallocations
return a ready error0.65 ns0
errors.New on every call29.11 ns
fmt.Errorf with %w162.02 ns2

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 deptherrors.Is
112.79 ns×1.0
532.80×2.6
1055.45×4.3
20105.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

Next they ask

When do you make a sentinel error and when your own type?

Short answer

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.

Next they ask

How do you implement Is or As for your own type?

Short answer

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 ==.

Next they ask

What is wrong with github.com/pkg/errors today?

Short answer

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.

Next they ask

How do you log an error without logging it three times?

Short answer

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.

Next they ask

What does errors.Is do with nil?

Short answer

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.

Next they ask

How many levels of wrapping is normal?

Short answer

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

Claim

%w and %v do the same thing, %w is just prettier

Actually

%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.

Claim

a sentinel error can be compared with ==

Actually

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.

Claim

a type assertion and errors.As are the same thing

Actually

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.

Claim

errors.Unwrap will unwrap any error

Actually

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.

Claim

errors.Join is a way to wrap an error with context

Actually

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.

Claim

wrapping is expensive, better return the error as is

Actually

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.

Claim

errors.New can be called anywhere

Actually

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.

Claim

wrapping everything with %w is always good

Actually

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

Will errors.Is find the base after %w and after %v, will == work, will As find the type deep in the chain, and will a type assertion find it?
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

errors.Is looks for the same base error. How many times dearer is that over a chain of ten wrappers than over a chain of one?
times

Knowledge check

Question 1 of 6

A function returned fmt.Errorf("ctx: %v", ErrNotFound). What does errors.Is(err, ErrNotFound) give the caller?

Sources & further reading

4 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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