---
title: 'Sentinel Errors'
source: 'https://academia.sh/en/courses/go-type-system/sentinel-errors'
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 defined at the package level has its identity standing in its value, not its text'
    - 'Shows that comparison with errors.Is and == gives the same result on an unwrapped error'
    - 'Proves by running it that an error whose identity changes makes the caller silently fall into the wrong branch'
---

# Sentinel Errors

An error value defined once at the package level has its identity standing not in its text but in itself: another errors.New value carrying the same text does not come out equal when compared with it, because both are two different values of type *errors.errorString. errors.Is gives the same result as ==. The limiting measure: when a sentinel error's identity changes, the caller silently falls into the wrong branch, and no warning is produced.

The previous lesson measured that a value-receiver error is compared structurally, a
pointer-receiver error by identity. This lesson carries that difference one step further:
when a caller wants to **recognize** a specific error, what does it rely on? The answer rests
on a common pattern in Go — the **sentinel error**: an exported error value defined once at
the package level. This lesson's question is: where does this value's identity stand, and why
is it not confused with another error carrying the same text?

This name comes from the same family as the **sentinel variable** in the Algorithms course
and the **sentinel value** in the Python course: there, a single value marking the end of an
array or a special position; here, a single value marking a failure **state**. The concept is
not rebuilt; what is shared is only the idea — a special value that reduces a search or check
loop to a single comparison, instead of writing a separate rule at every step.

## Same Text, Separate Identity

`errors.New` produces a new value every time it is called. The `lookupFirst` function below
returns the package-level `ErrNotFound` sentinel; `lookupSecond`, on the other hand, returns a
value carrying the same text but produced **separately** with `errors.New`. Both look as
though they are telling the user the same thing.

These two usually come from two separate packages in real code: a storage layer defines a
sentinel like `ErrNotFound`; the caller compares against this sentinel to handle the "record
did not exist" state specially. The problem starts when another layer — perhaps a third
package wanting to carry the same meaning — writes its own call to
`errors.New("record not found")`; even though the text looks the same to a reader, the two
values carry two separate identities, unaware of each other.

```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("%-49s %-9s %s\n", name, location, evidence)
}

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

```go
// sentinel.go — an error value defined at the package level is set apart by identity from another value with the same text
package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("record not found")

func lookupFirst(id int) error {
	if id != 1 {
		return ErrNotFound
	}
	return nil
}

func lookupSecond(id int) error {
	if id != 1 {
		return errors.New("record not found")
	}
	return nil
}

func main() {
	h1 := lookupFirst(9)
	h2 := lookupSecond(9)

	observe("dynamic type of two values", "value", fmt.Sprintf("%T, %T", h1, h2))
	observe("== comparison with the sentinel", "value", fmt.Sprint(h1 == ErrNotFound))
	observe("errors.Is comparison with the sentinel", "value", fmt.Sprint(errors.Is(h1, ErrNotFound)))
	observe("== result for a different same-text value", "value", fmt.Sprint(h2 == ErrNotFound))
	observe("errors.Is result for a different same-text value", "value", fmt.Sprint(errors.Is(h2, ErrNotFound)))
	observe("text comparison of two different values", "value", fmt.Sprint(h1.Error() == h2.Error()))
	summary()
}
```

```
dynamic type of two values                        value     *errors.errorString, *errors.errorString
== comparison with the sentinel                   value     true
errors.Is comparison with the sentinel            value     true
== result for a different same-text value         value     false
errors.Is result for a different same-text value  value     false
text comparison of two different values           value     true
binding: 0 use site, 6 value
```

**EH7.** The first line repeats the previous lesson's dynamic-type measurement here and gives
a surprising result: `h1` and `h2` carry the **same** dynamic type, `*errors.errorString` — in
the standard library, `errors.New` returns a pointer to this one type, no matter what text you
give it. So being the same type does not mean being the same value. `h1 == ErrNotFound`
returns `true` because `lookupFirst` really returned the same package-level variable — the
same pointer carrying the same address. `h2 == ErrNotFound`, on the other hand, returns
`false`, because `lookupSecond` produced a **new value** with its own `errors.New` call; even
though the text is exactly the same, this means a different struct standing at a different
memory location.

**EH8.** `errors.Is(h1, ErrNotFound)` and `errors.Is(h2, ErrNotFound)` give exactly the same
result as `==` on these two unwrapped errors. The reason is `errors.Is`'s behavior: if the
target error is not wrapped, the comparison falls back directly to `==`. This equivalence
holds only in this lesson — the next lesson measures why `==` becomes insufficient once an
error is wrapped, and why `errors.Is` becomes necessary. The last line, in turn, shows that
text comparison (`Error() == Error()`) answers a question entirely separate from identity
comparison: two different values return `true` because their text is exactly the same — a
shortcut that would give the wrong answer to the identity question.

This measurement also explains why the sentinel error pattern is built with `var`, not
`const`. Because `error` is an interface, it cannot be bound to a constant — constants can
only be defined on basic types (number, string, bool). A sentinel error behaving like a
"constant" does not come from a language restriction, it comes from the entire package
accessing, with the same pointer, a single variable created once at the package level and
never changed again. Preserving identity depends on this single variable being **shared**; if
every caller produces its own `errors.New` copy, identity comparison loses its meaning from
the very start.

## Custom Identity: Comparison Established With the Is Method

The behavior of `errors.Is` measured in this lesson — falling back to `==` on an unwrapped
error — is not its only rule. The `errors` package gives an error the option of handing
comparison off to a method it defines itself: a type writing `Is(target error) bool`, when
called by `errors.Is`, answers according to its own logic, not pointer equality. This shows
that identity does not always mean "the same address" — a type can write its own definition
of identity.

```go
// codeerror.go — an identity established with a custom Is method: identity can be defined without pointer equality
package main

import (
	"errors"
	"fmt"
)

type CodeError struct{ Code int }

func (k *CodeError) Error() string { return fmt.Sprintf("error code %d", k.Code) }

func (k *CodeError) Is(target error) bool {
	h, ok := target.(*CodeError)
	return ok && h.Code == k.Code
}

func main() {
	a := &CodeError{Code: 404}
	b := &CodeError{Code: 404}
	c := &CodeError{Code: 500}

	observe("== result for two separate pointers", "value", fmt.Sprint(a == b))
	observe("comparison of the same code via the Is method", "value", fmt.Sprint(errors.Is(a, b)))
	observe("comparison of a different code via the Is method", "value", fmt.Sprint(errors.Is(a, c)))
	summary()
}
```

```
== result for two separate pointers               value     false
comparison of the same code via the Is method     value     true
comparison of a different code via the Is method  value     false
binding: 0 use site, 3 value
```

**EH9.** `a` and `b` are two separate pointers of type `CodeError`; `==` compares them by
address and returns `false`, just like the `AccessError` example in the previous lesson. But
when `errors.Is(a, b)` is called, the result is `true` — because `CodeError` has written the
`Is(target error) bool` method, and `errors.Is` asks `a`'s `Is` method about the target.
`a.Is(b)` confirms that `b` is also a `*CodeError` and that its `Code` field is equal; the
address never comes into play.

**EH10.** `errors.Is(a, c)`, on the other hand, returns `false`, because `c`'s `Code` field is
different — the custom `Is` method is still running, only this time it says "not equal"
according to its own logic. This shows that identity in Go is not tied to a single mechanism:
if a package-level shared variable's address is one form of identity, a type's own `Is`
method is another form of identity. `errors.Is` accepts both — it first tries `==`, then
checks whether the target or the source has an `Is` method. The sentinel error measurement in
the next section relies on the first form, the shared variable.

The choice between these two forms depends on the information the error carries. For a state
carrying no extra data at all, like `ErrNotFound`, a single shared value is enough — every
caller can look at the same address. But for an error kind carrying its own changing data,
like `CodeError` (`Code`), defining a separate shared variable ahead of time for every
possible code is not practical; the `Is` method reduces the question "is this the same type
and the same code" to a single comparison, and removes the need to produce a new sentinel for
every code.

## Limiting Measure: Identity Is an API Promise

When a sentinel error is exported (a name starting with a capital letter, like
`ErrNotFound`), the package itself makes a promise: this value will always represent a
specific error state returned from this package with **the same pointer**. When this promise
is broken — when a change is made that produces the same state with a different `errors.New`
call — no warning appears in the source.

This promise usually breaks as a codebase grows, out of carelessness rather than bad intent.
While a package's first version returns `ErrUnauthorized` from a single place, a second
function reporting the same state gets added over time; if the person writing it writes their
own `errors.New` call instead of importing the package-level sentinel — perhaps while moving
between files, perhaps because they are unaware the sentinel exists — the two functions now
produce two separate errors carrying the same text but a different identity. The measurement
below builds this scenario exactly: `accessA` shares the sentinel, `accessB` does not.

```go
// version.go — if a sentinel error's identity changes, the caller silently falls into the wrong branch
package main

import "errors"

var ErrUnauthorized = errors.New("unauthorized access")

func accessA(rank int) error {
	if rank < 2 {
		return ErrUnauthorized
	}
	return nil
}

func accessB(rank int) error {
	if rank < 2 {
		return errors.New("unauthorized access")
	}
	return nil
}

func handle(err error) string {
	if err == ErrUnauthorized {
		return "fell into the unauthorized branch, special handling applied"
	}
	if err != nil {
		return "fell into the unknown-error branch, general handling applied"
	}
	return "no error"
}

func main() {
	observe("handle with accessA's sentinel error", "value", handle(accessA(0)))
	observe("handle with accessB's same-text error", "value", handle(accessB(0)))
	summary()
}
```

```
handle with accessA's sentinel error              value     fell into the unauthorized branch, special handling applied
handle with accessB's same-text error             value     fell into the unknown-error branch, general handling applied
binding: 0 use site, 2 value
```

**EH11.** `accessA` and `accessB` apparently report the same state to the user —
"unauthorized access" — but only `accessA` returns the real `ErrUnauthorized` sentinel. The
`handle` function has separated out a special branch with `err == ErrUnauthorized`:
`accessA`'s error falls into this branch, `accessB`'s does not and slides into the general
branch. For someone reading the source, because `accessB`'s text is exactly the same as
`accessA`'s, it is not possible to see that this distinction exists — only running it and
comparing the results shows it.

**EH12.** This shows why a sentinel error's identity is counted as an **API promise**: a
function like `accessB`, even though it thinks it reports the same state, does not keep the
promise because it does not share the sentinel. Compilation says nothing, because both return
a valid `error`; no panic or error message appears at run time either — `handle` just
silently falls into the unexpected branch. Once a package publishes a sentinel error, every
path that produces that error must return the same variable; otherwise the caller side's
identity comparison secretly starts giving the wrong result.

This also has a limit: identity is meaningful only **within the same running process**.
`ErrUnauthorized`'s address is specific to that one run of the program; writing an error to
disk and reading it back, or carrying it to another process with a network call, carries not
the `error` value but its `Error()` text — identity is lost in this crossing. This is why
sentinel error comparison is reliable only between calls within the same binary (the same
build); across a boundary (an HTTP response, a log file) the error text or a separate error
code field needs to be carried instead.

When the three findings measured in this lesson come together, the topic's measure settles,
for the first time among error values, entirely into the **value** class. Implicit
satisfaction was established at the use site, as the previous lesson also showed; but
**recognizing** an error, telling it apart from another error, rests entirely on the value's
own identity — there is neither a declaration in the source, nor a check the compiler looks
at. Identity rests either on a shared address (`ErrNotFound`) or on an `Is` method the type
writes itself; both are learned only at run time, by asking the value.

## Summary

- A sentinel error is an `error` value defined once at the package level and exported; its
  identity stands not in its text, but in its own value.
- Every call to `errors.New` produces a new `*errors.errorString` value — even given the same
  text, two calls mean two different values, and therefore two different identities.
- On an unwrapped error, `errors.Is` and `==` give the same result; this equivalence holds
  only when the error is never wrapped.
- Text comparison (`Error() == Error()`) does not substitute for identity comparison: two
  errors with different identities can carry exactly the same text.
- A sentinel error's identity is an API promise; if every path reporting a state does not
  return the same shared variable, the caller's identity check produces the wrong result
  without any warning.
- Identity can rest not only on a shared address, but also on an `Is(target error) bool`
  method a type writes itself; `errors.Is` recognizes both, `==` only the first. Identity is
  valid only within the same running process, and is lost when crossing a boundary (writing
  to disk, carrying over a network).

## Next Step

The errors measured in this lesson were always single-layer: a function either returned the
sentinel directly, or produced a new value in its place. But 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." Once an error is wrapped this way,
`h1 == ErrNotFound` no longer works; the outer value is no longer the sentinel itself, it is
another value carrying it inside. The observation that `errors.Is` falls back to `==` on
unwrapped errors, measured in this lesson, also ends here: once a chain is added in the next
lesson, it measures why `errors.Is` now produces a different result from `==`, and how many
layers down the chain the sentinel at the bottom can still be found. The same question will
also be asked for the wrapping text itself: how is the context a layer adds preserved through
the rest of the chain?
