Lesson 07 / 13
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.
Published Updated
In this lesson
- 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
Contents
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.
// 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)
}
// 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.
// 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.
// 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
errorvalue defined once at the package level and exported; its identity stands not in its text, but in its own value. - Every call to
errors.Newproduces a new*errors.errorStringvalue — even given the same text, two calls mean two different values, and therefore two different identities. - On an unwrapped error,
errors.Isand==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) boolmethod a type writes itself;errors.Isrecognizes 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?
To keep your progress and take notes, Log in
My notes
Log in to take notes.