skip to content

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