skip to content

Why should a caller use errors.Is(err, io.EOF) instead of comparing err == io.EOF?

level: middleimportance: must knowfreq 78%

answer

  1. == looks at exactly one value
  2. context added on the way up makes a new value
  3. the original is still reachable underneath
  4. one of them walks a chain
  5. a type can also declare a match itself

basics

~20 s

The == operator only tests the outermost error value. If any layer in between returned a new error carrying the original as its cause, == stops matching. errors.Is walks that chain of causes and matches any error in it.

solid answer

~40 s

`==` compares one value against one value. The moment a layer between the source of the error and you returns a new error that carries the original as its cause, the value you hold is the outer one, so `err == io.EOF` becomes false — silently, with no compile error and no vet warning. `errors.Is(err, io.EOF)` starts at the error you have, compares it with the target, then unwraps to the cause and repeats until the chain runs out. It also lets an error type opt in to matching by implementing `Is(error) bool`, so a type can declare itself equivalent to a sentinel. `errors.Is` is a superset of `==` for this purpose: it gives the same answer when nothing wrapped, and the right answer when something did. Write it from the start.

code

go · 9 lines
go
var ErrNotFound = errors.New("record not found")

func load(id string) error {
	return fmt.Errorf("load %s: %w", id, ErrNotFound)
}

err := load("42")
fmt.Println(err == ErrNotFound)          // false
fmt.Println(errors.Is(err, ErrNotFound)) // true

go deeper

for a junior

Remember the rule and the reason in one line: use errors.Is because == only sees the outermost error, and errors give up their outer layers only when you unwrap them.

for a middle

Be ready to describe the loop errors.Is runs — compare, consult any Is method, unwrap, repeat — and to explain why it is a strict superset of == for sentinel matching.

for a senior

Show how you find and prevent the broken form: no compiler or vet check catches it, so you rely on review plus a test that wraps the sentinel a layer deep before asserting, and you fix the layer that dropped a cause rather than pattern-matching on text.

for a principal

Argue the standard as a policy: matching helpers everywhere, no text matching, and a documented expectation that layers preserve causes, so that classification keeps working as call chains grow between teams.

## The two things being compared A sentinel error is a single package-level value — `io.EOF`, `sql.ErrNoRows`, your own `ErrNotFound` — and a caller wants to know whether the error it is holding *means* that condition. With `==` the caller asks a narrower question: "is the value I am holding literally that value?" Those two questions coincide only when the error travelled from its origin to the caller untouched. ## How the chain forms Go code routinely returns a new error that carries the original inside it, so that the final message reads `load user 42: sql: no rows in result set` instead of a bare `sql: no rows in result set`. The value the top-level caller receives is now the *outer* error. The sentinel is still in there, reachable through the outer error's `Unwrap() error` method, but it is no longer the value in the caller's hand: ```go var ErrNotFound = errors.New("record not found") func load(id string) error { return fmt.Errorf("load %s: %w", id, ErrNotFound) } err := load("42") fmt.Println(err == ErrNotFound) // false fmt.Println(errors.Is(err, ErrNotFound)) // true ``` This is what makes the `==` form dangerous rather than merely limited. It compiles, it passes review, and it passes a unit test that calls the innermost function directly. It fails only in the assembled program, and it fails *quietly*: the branch that should have said "no such record" is skipped and the error falls through to the generic failure path. ## What errors.Is actually does `errors.Is(err, target)` runs a loop: 1. If `target` is comparable, compare the current error with it using `==`. Match means done. 2. If the current error has a method `Is(error) bool`, call it with the target. Returning true also means done. 3. Unwrap: if the current error has `Unwrap() error`, move to what it returns and go back to step 1. 4. When there is nothing left to unwrap, return false. Two consequences are worth stating explicitly. **It is a superset of `==` for sentinels.** With no wrapping in the way, step 1 gives exactly the answer `==` would. There is no case where `==` is right and `errors.Is` is wrong, so there is no reason to prefer `==` — including in the innermost package where "nothing wraps this yet" is true only until someone adds a layer. **Types can opt into matching.** A concrete error type that implements `Is(error) bool` can declare itself equivalent to a sentinel, which is how one error value can match a broad category. This only ever triggers through `errors.Is`; `==` cannot consult it. Since Go 1.20 an error may also unwrap to *several* causes via `Unwrap() []error`, which is how joined errors work; `errors.Is` walks the resulting tree rather than a single line, and reports a match if the target is anywhere in it. ## Things it does not fix - **A layer that discards the cause.** If an intermediate function formats the original into a message without keeping it as a cause, the chain is cut, and no amount of `errors.Is` at the top will find the sentinel. Matching is a two-sided contract: the producing side has to preserve the cause. - **Text matching.** `strings.Contains(err.Error(), "no rows")` is the other thing people reach for, and it is worse than `==`: it depends on message wording that is not part of any package's contract and changes without notice. - **A missing sentinel.** If the underlying package never exported a value for the condition, there is nothing to match, and no comparison helper will invent one. ## Practical guidance - Treat `err == SomeSentinel` as a defect wherever you see it, including `err == io.EOF`. The `io.Reader` contract is one of the few places the untouched value is genuinely conventional, and even there `errors.Is` is what current code writes. - Match on the sentinel closest to the condition you actually care about, and let the rest of the error fall through to your generic handler. - Because nothing in the compiler or `go vet` flags the `==` form, the guard has to be a test: assert that your matching still works on an error that has been wrapped at least one layer deep. ## Version note `errors.Is`, `errors.As` and the `Unwrap` convention entered the standard library in Go 1.13. Code written before that had no alternative to `==` and to type assertions, which is why the pattern still shows up in older codebases and in answers repeated from them.

  • Does errors.Is ever give a different answer from == when no layer has added context?
    No. With nothing to unwrap, errors.Is compares the error with the target and returns exactly what == would. That is the argument for always writing errors.Is: it can only be right where == is right, and it stays right when someone later adds a layer between the source and the caller.
  • What does an error type gain by implementing Is(error) bool?
    It can declare itself equivalent to a sentinel target, so `errors.Is` reports a match even though the values are not equal. That lets one concrete error stand for a whole category of conditions. Only `errors.Is` consults the method — a direct `==` comparison never calls it.
  • Why is matching on strings.Contains(err.Error(), "no rows") worse than either option?
    Message text is not part of any package's contract. It can be rephrased in a point release, it changes as outer layers prepend context, and it is locale- and format-dependent. The comparison also silently matches unrelated errors that happen to contain the phrase. Match values, never rendered text.
  • If errors.Is returns false even though the underlying cause was a sentinel, what is the most likely explanation?
    Some layer built a new error from the original's text instead of keeping it as a cause, so the chain was cut there. errors.Is can only walk what the producing side preserved. Fix the layer that dropped the cause rather than adding a text comparison at the top.

saying these in an interview costs you the question

  • Claiming errors.Is compares the two errors' message strings
  • Believing == is fine because the error is not wrapped yet
  • Expecting the compiler or go vet to catch a broken == match
  • Thinking errors.Is can find a cause a layer discarded
  • Falling back to strings.Contains on err.Error() when == fails