skip to content

Sentinels, Types and Matching

Callers can only react to a failure they can name, and Go offers two ways to name one: a package-level sentinel or a struct type carrying fields. Choosing between them is API design.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

16

What is a sentinel error in Go, and how do you declare one so callers can detect it?

level: juniorimportance: must knowfreq 70%

answer

  1. one value, created once
  2. package level, not inside the function
  3. the name starts with Err
  4. errors.New returns a distinct value each call
  5. callers match the exported variable, not text

basics

~20 s

A sentinel error is one package-level error value, such as var ErrNotFound = errors.New("not found"), that a package returns for a specific condition. Callers detect that condition by matching a returned error against that exported value.

solid answer

~40 s

A sentinel is a single, long-lived `error` value declared once at package level — `var ErrNotFound = errors.New("store: record not found")` — and returned every time the package hits that one condition. It works because `errors.New` returns a fresh, distinct value on each call, so identity alone identifies the condition: two separate `errors.New("not found")` calls are never equal to each other. Callers test for it with `errors.Is(err, store.ErrNotFound)`. The standard library uses the same pattern: `io.EOF`, `sql.ErrNoRows`, `os.ErrNotExist`. The naming convention is an exported `Err` prefix, and the message is usually prefixed with the package name so it still reads well once other layers add context to it.

code

go · 11 lines
go
package store

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

func Lookup(id string) (Record, error) {
	rec, ok := records[id]
	if !ok {
		return Record{}, ErrNotFound
	}
	return rec, nil
}

go deeper

for a junior

Be able to write the declaration from memory: a package-level var, an Err-prefixed name, errors.New with a lower-case package-prefixed message. Know that io.EOF and sql.ErrNoRows are the same pattern.

for a middle

Explain why identity works: errors.New returns a distinct value per call, so equality of the value, not the message, carries the meaning. Explain why a const or an in-function errors.New breaks it.

for a senior

Show that you return the sentinel consistently for exactly one condition, that callers are told to use errors.Is, and that you never make branching depend on the message text a log formatter might change.

for a principal

Frame the naming and message conventions as something the codebase enforces rather than rediscovers: an Err-prefixed exported value is a promise other teams will code against, so the shape should be uniform across your packages.

## What a sentinel error is Go functions report failure by returning a value of the built-in interface type `error`, which has exactly one method, `Error() string`. That leaves an obvious question: if failure is just a value, how does a caller tell *which* failure happened — "the record was not there" (usually normal) versus "the database is unreachable" (usually not)? A **sentinel error** is the simplest answer. The package declares one error value, once, at package scope, and returns that same value every time that one condition occurs: ```go package store var ErrNotFound = errors.New("store: record not found") ``` The caller then asks: "is the error I got this particular value?" ## Why it works: identity, not text `errors.New` allocates a new value each time it is called and returns a pointer to it. Two calls with the *same* message produce two values that are **not** equal: ```go a := errors.New("boom") b := errors.New("boom") fmt.Println(a == b) // false ``` That is the whole trick. Because the value is unique, the *identity* of the value carries the meaning, and the message text is only for humans. This is why a sentinel must be a package-level `var` evaluated once. Two common beginner mistakes break it: - Building the error inside the function (`return errors.New("not found")`) — every call produces a different value, so no caller can ever match it. - Returning it from a helper function (`func ErrNotFound() error { ... }`) — same problem. It also cannot be a `const`. Go constants must be constant expressions known at compile time; `errors.New(...)` is a function call, so the declaration must be a `var`, initialised during package initialisation. ## Naming and message conventions - The identifier starts with `Err` and, if callers are meant to match it, it is exported: `ErrNotFound`, `ErrClosed`, `ErrTimeout`. A type that *is* an error is named the other way round (`ParseError`); a value is `ErrX`. - The message is lower-case and has no trailing punctuation, because error strings get embedded inside longer sentences as outer layers add context. - The message is usually prefixed with the package name — `"sql: no rows in result set"`, `"store: record not found"` — so that when a caller prints the final error, the reader can see which package originated it. ## The standard library's own sentinels You already use several, and recognising them by name is part of reading Go code: - `io.EOF` — declared as `var EOF = errors.New("EOF")`. A `Read` returns it to signal a *graceful* end of input; it is a normal control signal, not a malfunction. - `sql.ErrNoRows` — returned by `(*sql.Row).Scan` when the query selected no rows. Again, usually an expected outcome, not a database failure. - `os.ErrNotExist`, `os.ErrExist`, `os.ErrPermission` — the file-system conditions. ## How a caller matches one Use `errors.Is`, not `==`: ```go rec, err := store.Lookup(id) if errors.Is(err, store.ErrNotFound) { fmt.Println("no such record") return nil } if err != nil { return err } ``` `==` happens to work when the error comes back untouched, but any layer in between that adds context to the error produces a *different* outer value, and the `==` test then silently stops matching while still compiling. `errors.Is` looks through those layers. Writing `errors.Is` from the start costs nothing and survives a caller chain that grows later. ## What it does not do A sentinel carries no data. It can say "not found"; it cannot say *which* id was not found, or which field failed validation, unless the layers around it add that as context. It is deliberately the cheapest classification mechanism Go has: one exported value, one meaning, one comparison. ## Quick checklist - Package-level `var`, not a local, not a function, not a `const`. - Exported only if callers are supposed to branch on it. - `Err` prefix; lower-case, unpunctuated, package-prefixed message. - Match with `errors.Is`, never with the `Error()` string.

  • Why can a sentinel error not be declared with const?
    Go constants must be constant expressions the compiler can evaluate. `errors.New("...")` is a function call that allocates a value at run time, so it can only initialise a package-level `var`, which runs during package initialisation. There is no compile-time error value in Go.
  • Why do standard library error messages start with the package name, like "sql: no rows in result set"?
    Error strings are meant to be embedded in longer messages as outer layers add context, so the final line reads as one sentence. The package prefix survives that concatenation and tells whoever reads the log which package produced the original failure. That is also why the messages are lower-case and unpunctuated.
  • Is io.EOF an error condition you should log as a failure?
    No. `io.EOF` is a control signal meaning input ended cleanly — a `Read` returns it when there is nothing more to read. Treat it as the normal loop-termination case. Only an unexpected truncation is a real fault, and the standard library signals that with a different value, `io.ErrUnexpectedEOF`.

saying these in an interview costs you the question

  • Creating the error with errors.New inside the function on every call
  • Assuming two errors.New values with the same text are equal
  • Detecting the condition by comparing err.Error() text
  • Declaring the sentinel as a const, which does not compile
  • Returning the sentinel from a helper function instead of a var
open as a page

How do you define a custom Go error type that carries structured fields a caller can read?

level: juniorimportance: must knowfreq 72%

basics

~20 s

Declare a struct holding the data and give it an Error() string method, normally on a pointer receiver. Any type with that method is usable as an error, and callers read the exported fields off the concrete value.

open as a page

When should a Go package export a sentinel error value rather than an exported error type?

level: middleimportance: must knowfreq 68%

basics

~20 s

Export a sentinel value when the caller only needs a yes-or-no branch, and an error type when the caller needs data to act on. A sentinel commits the package to one identity; a type commits every exported field forever.

open as a page

How should a Go package carry retryability inside the error value it returns?

level: middleimportance: must knowfreq 62%

basics

~20 s

Put the classification in the error itself: an exported sentinel matched with errors.Is, or an error type with a method such as Retryable() bool found with errors.As. Both survive wrapping; a second bool return and message text do not.

open as a page

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

level: middleimportance: must knowfreq 78%

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.

open as a page

What must the target argument to errors.As be, and what happens if it is not a pointer?

level: middleimportance: must knowfreq 66%

basics

~20 s

The target must be a non-nil pointer to a type implementing error, or to an interface type. errors.As writes the match through it, so anything else, including a plain value or a nil pointer, panics at runtime instead of returning false.

open as a page

Why is matching on an error's Error() text a defect in Go, and what must a package export instead?

level: juniorimportance: should knowfreq 55%

basics

~20 s

An error's Error() text is documentation, not contract: a package can reword it in any release and the caller's match stops firing, with no compile error. The package must export something matchable instead, a sentinel value or an error type.

open as a page

Why is an error that matches context.Canceled never worth retrying in Go?

level: juniorimportance: should knowfreq 50%

basics

~20 s

context.Canceled means someone deliberately called the work off, so the cause never clears. Any attempt reusing that context fails immediately, and a fresh one produces a result nobody is waiting for. Stop and return the error.

open as a page

A Go queue worker retries every non-nil error and is overloading a failing downstream. How do you fix its classification?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Replace the retry-everything default with one classify function mapping an error to retryable, terminal or abandoned via errors.Is and errors.As, cap attempts with the envelope's delivery count, and count failures by error class and attempt number.

open as a page

Your CLI tests err == sql.ErrNoRows and now reports "lookup failed" for ids that do not exist. How do you diagnose and fix it?

level: seniorimportance: should knowfreq 54%

basics

~20 s

A layer between the query and the CLI now returns a new error carrying the original as its cause, so the outermost value is no longer sql.ErrNoRows and == silently stops matching. Fix it with errors.Is.

open as a page

errors.As never finds the cause your custom error type stores in a field. Why?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Because storing a cause in a field does not link it into the error chain. errors.As only descends by calling an Unwrap() error method, so a type that keeps its cause in a field without that method is where the search stops.

open as a page

You own a semver-parsing library three teams import and cannot patch. How do you decide which failures get an exported error surface?

level: principalimportance: should knowfreq 34%

basics

~20 s

Export a surface only where a named importer has a branch that changes behaviour, one shape per condition, and treat every exported error as permanent API. Everything else stays an unexported error with a good message.

open as a page

How does a value versus pointer receiver on Error() string change which errors.As target matches?

level: middleimportance: nice to knowfreq 38%

basics

~20 s

A pointer receiver puts Error only in the method set of *T, so the package can only return *T and callers must target a *T variable. A value receiver lets both T and *T be errors, so the target must match whichever form the package actually returns.

open as a page

When is an exported IsX(err error) bool predicate a better package surface than exporting the error value itself?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

A predicate over an unexported error keeps the representation private, so the package can change how that failure is built without breaking importers. The cost: callers can only ask the question, never construct or compose the failure themselves.

open as a page

What does a package commit to when it exports var ErrNotFound as part of its API?

level: seniorimportance: nice to knowfreq 34%

basics

~20 s

An exported sentinel commits the package to returning an error that still matches ErrNotFound for that condition in every release. The promise is enforced only at run time, so quietly stopping breaks callers silently rather than at compile time.

open as a page

In Go, should a library's error value declare its own retryability, or should the caller classify it?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Export facts, not verdicts. A library knows what happened — whether the request was sent, what the peer said — but not the caller's cost of a duplicate effect. Declare retryability only where the library alone can know it.

open as a page