Sentinel Errors — Specification¶
Table of Contents¶
- Introduction
- Sentinels Are Not in the Spec
- The Underlying Spec Mechanics
- Standard Library Conventions
- The Naming Convention
errors.IsSemantics- Wrapping with
%w - Cross-Package Aliases
- Differences Across Go Versions
- What the Convention Does NOT Promise
- References
Introduction¶
The Go specification defines the language. Sentinel errors are not a language feature — they are a community-and-stdlib convention layered on the small core defined by the spec. This file separates "what is in the spec" from "what is in stdlib practice."
Reference: The Go Programming Language Specification.
Sentinels Are Not in the Spec¶
A search of the Go specification for the word "sentinel" returns zero results. The specification mentions:
- The predeclared
errorinterface. - Multi-valued returns.
- Interface equality.
- Variable declarations.
…but says nothing about a "sentinel error pattern." The pattern is built entirely on top of these primitives:
…uses only:
- A package-level
vardeclaration (spec: Variable declarations). - A call to
errors.New(defined by theerrorspackage, not by the spec). - An interface assignment in the return.
- The
errors.Isfunction (defined by theerrorspackage, not by the spec).
Implication: every rule about sentinels in this section is a standard library or community rule, not a language rule.
The Underlying Spec Mechanics¶
Three spec rules make the sentinel pattern work:
Rule 1: Package-level variables persist for the program's lifetime¶
From the spec, Package initialization:
Within a package, package-level variable initialization proceeds stepwise, with each step selecting the variable earliest in declaration order which has no dependencies on uninitialized variables. [...] Variables may also be initialized using functions named
initdeclared in the package block, with no arguments and no result parameters.
A package-level var ErrFoo = errors.New("foo") is initialized exactly once, before main runs. The resulting interface value is stable for the entire process.
Rule 2: Interface equality compares dynamic type and value¶
From the spec, Interface types:
Two interface values are equal if they have identical dynamic types and equal dynamic values or if both have value nil.
Sentinel comparison with == reduces to "are these the same dynamic type and the same dynamic value?" For two distinct calls returning the same ErrFoo, both conditions hold and == returns true.
For wrapped errors, the outer error has dynamic type *fmt.wrapError, not *errors.errorString, so == against the sentinel is false. This is why errors.Is exists — to walk the chain instead of comparing only the outer header.
Rule 3: The error interface admits any type with Error() string¶
From the spec, Predeclared identifiers:
error
The error type is predeclared. Any package-level variable declared as error (or assigned an error value via errors.New) is a valid sentinel candidate.
That is the entire spec contribution. Everything else is convention.
Standard Library Conventions¶
The standard library encodes a few rules by example. They are not mandatory but breaking them confuses every other Go programmer.
Convention 1: Package-level var block¶
Sentinels live in a single var (...) block, usually at the top of errors.go:
package mypkg
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)
Convention 2: Lowercase, no trailing punctuation¶
The Go standard library's error strings are fragments that compose into longer messages via wrapping. Capitalization and punctuation in the middle of a wrapped chain look wrong:
vs
Convention 3: Documented as exported¶
// ErrNotFound is returned by Get when no record matches the key.
var ErrNotFound = errors.New("not found")
The doc comment explains when the sentinel is returned, not just what it says.
Convention 4: Consistent within a package¶
A package picks one error pattern (sentinels, typed errors, kinds) and uses it throughout. Mixing without good reason confuses callers.
The Naming Convention¶
Sentinels start with Err (capitalized for export, lowercase for package-private):
var ErrNotFound = errors.New("not found") // exported
var errCacheMiss = errors.New("cache miss") // unexported
Codified by:
- The standard library's actual practice (every stdlib sentinel starts with
Err). golangci-lintstyle checks.- Go community style guides (Effective Go, Google Go Style Guide).
Historical exceptions in the stdlib: - io.EOF — predates the convention; kept for compatibility.
If you write a new sentinel today, prefix it with Err. Anything else fights the ecosystem.
errors.Is Semantics¶
From the documentation of errors.Is in pkg/errors:
Isreports whether any error inerr's tree matchestarget.The tree consists of
erritself, followed by the errors obtained by repeatedly callingUnwrap. Whenerrwraps multiple errors,Isexamineserrfollowed by a depth-first traversal of its children.An error is considered to match a target if it is equal to that target or if it implements a method
Is(error) boolsuch thatIs(target)returns true.
Three semantics worth memorizing:
- Equality (
==) is the base check. Two interface values are equal iff dynamic types and values match. - Custom
Ismethod can broaden matching: a typed error can declare it matches a sentinel. Unwraptraversal lets the check pass through wrappers transparently.
Edge cases per the docs:
errors.Is(nil, nil)istrue.errors.Is(err, nil)istrueonly iferr == nil.errors.Is(nil, target)isfalsefor non-niltarget.
Wrapping with %w¶
From fmt's documentation:
The verb
%wis a special directive that wraps the supplied error. It calls theErrorffunction and the resulting error implements anUnwrapmethod returning the wrapped error.
Rules:
%wis only valid infmt.Errorf, notfmt.Sprintforfmt.Printf.- Up to one
%wper format string in Go 1.13–1.19. - Multiple
%wallowed in Go 1.20+ (the resulting error implementsUnwrap() []error). - If
%wis given a non-error argument, the result is the literal%!w(...)— always pass anerror.
The wrap preserves the wrapped value for errors.Is and errors.As traversal.
Cross-Package Aliases¶
When the standard library wants two packages to share a sentinel, it does so by value assignment:
// io/fs/fs.go
var ErrNotExist = errInvalid // wraps an internal value
// os/error.go
var ErrNotExist = fs.ErrNotExist // alias to fs's value
The second var re-uses the first's interface value. errors.Is(err, fs.ErrNotExist) and errors.Is(err, os.ErrNotExist) both succeed when the underlying error is the shared one.
Implication for your own code: if you want package B to extend package A's vocabulary, do not redeclare the sentinel — alias it:
This is the recognized way to extend an error vocabulary across packages without breaking identity.
Differences Across Go Versions¶
| Go version | Relevant change |
|---|---|
| 1.0 | errors.New and the convention of using package-level var for sentinels. |
| 1.13 | errors.Is, errors.As, errors.Unwrap, fmt.Errorf %w. The wrap chain becomes the canonical way to attach context to a sentinel. |
| 1.16 | io/fs introduced; fs.ErrNotExist etc. aliased to existing os sentinels. |
| 1.20 | errors.Join for combining errors; multiple %w in fmt.Errorf; tree-shaped wrap chains. |
| Modern (1.21+) | The implementation continues to evolve; behavior is stable. |
Old code that uses == against sentinels (pre-1.13 idiom) still compiles and runs unchanged. New code can adopt errors.Is everywhere; the cost is identical for unwrapped errors.
What the Convention Does NOT Promise¶
The sentinel convention is convention. Specifically not guaranteed:
- The compiler does not enforce sentinel use. You can declare
var X = errors.New("x")and never return it; the compiler will not warn. - The compiler does not warn for
==against a sentinel. It is legal Go; only linters flag it. - No tooling enforces the
Errprefix. Convention only. errors.Isis not part of the language. It is a regular function in theerrorspackage; you can write your own.- Wrapping is not automatic. A package can return a sentinel bare or wrapped — readers must check the docs.
- Cross-package sentinels work via pointer identity. Plugins, multiple imports, and dynamic linking can produce duplicates and break detection.
- No language-level stack traces. A wrap chain shows what operations failed; not where in source code.
This is by design: the spec keeps the language small; conventions and stdlib idioms keep the ecosystem coherent.
References¶
- The Go Programming Language Specification — Predeclared identifiers
- The Go Programming Language Specification — Variable declarations
- The Go Programming Language Specification — Package initialization
- The Go Programming Language Specification — Interface types
- Package errors documentation
- Package fmt documentation — Errorf
- Go 1.13 release notes — error wrapping
- Go 1.16 release notes — io/fs
- Go 1.20 release notes — errors.Join
$GOROOT/src/errors/errors.go$GOROOT/src/errors/wrap.go$GOROOT/src/io/io.go$GOROOT/src/io/fs/fs.go$GOROOT/src/os/error.go$GOROOT/src/database/sql/sql.go