Sentinel Errors & errors.Is
Sentinel errors are package-level variables that represent a fixed failure identity.
Callers recognize them with errors.Is instead of string matching or brittle == checks that break once errors are wrapped.
Summary
Sentinel errors give your API a small vocabulary of stable outcomes: not found, already exists, canceled, invalid input.
Go 1.13 added errors.Is so those identities survive fmt.Errorf("...: %w", err) wrap chains.
Use sentinels for cross-package contracts; reach for custom types when callers need structured fields.
Recipe
Quick-reference recipe card - copy-paste ready.
var ErrNotFound = errors.New("not found")
func Find(id string) (Item, error) {
if id == "" {
return Item{}, ErrNotFound
}
return Item{}, nil
}
if errors.Is(err, ErrNotFound) {
// handle missing resource
}When to reach for this:
- A failure mode is part of your public API contract
- Callers need a boolean branch without parsing strings
- The error has no extra fields beyond its identity
- Standard library patterns (
io.EOF,os.ErrNotExist) apply to your domain
Working Example
package main
import (
"errors"
"fmt"
"os"
)
var ErrUserNotFound = errors.New("user not found")
func loadUser(path string) error {
_, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("load user %q: %w", path, ErrUserNotFound)
}
return fmt.Errorf("load user %q: %w", path, err)
}
return nil
}
func main() {
err := loadUser("alice.json")
switch {
case errors.Is(err, ErrUserNotFound):
fmt.Println("client: 404 user missing")
case err != nil:
fmt.Println("client: 500", err)
default:
fmt.Println("ok")
}
}What this demonstrates:
- A domain sentinel (
ErrUserNotFound) maps to a client outcome fmt.Errorfwith%wpreserves bothosand domain sentinels in the chainerrors.IsfindsErrUserNotFoundthrough wrapsos.IsNotExistis a convenience wrapper arounderrors.Is(err, os.ErrNotExist)
Deep Dive
How It Works
errors.Newreturns an opaque error value with a fixed identity.- Wrapping with
%wimplementsUnwrap() error, linking outer and inner errors. errors.Is(err, target)returns true iferr == targetor any unwrap step matches.- Direct
err == ErrXfails when a wrapper sits between caller and sentinel.
Sentinel Naming and Placement
| Pattern | Example | Guidance |
|---|---|---|
| Exported sentinel | var ErrNotFound = errors.New(...) | Part of public contract |
| Unexported sentinel | var errStale = errors.New(...) | Internal package only |
| Standard library | io.EOF, context.Canceled | Use errors.Is, not string contains |
io.EOF and Control Flow
io.EOF signals end of stream during Read, not necessarily a catastrophic failure.
Loop until errors.Is(err, io.EOF) after processing the final chunk.
Go Notes
// Bad: breaks after wrap
if err == ErrNotFound { ... }
// Good
if errors.Is(err, ErrNotFound) { ... }
// Also good for stdlib helpers
if os.IsNotExist(err) { ... }Gotchas
- Using
==after wrapping - Wrapped errors never equal the sentinel. Fix: Useerrors.Is. - Too many sentinels - Dozens of
ErrXvariables become hard to document. Fix: Group related failures into a typed error or error code enum. - Exporting internal sentinels - Callers depend on values you cannot evolve. Fix: Export only stable contract errors; keep helpers unexported.
- String matching with
strings.Contains- Fragile across wraps and wording changes. Fix:errors.Isorerrors.As. - Returning typed nil pointers -
return (*MyError)(nil)makeserr != niltrue. Fix:return nilorvar err error = nil. - Treating every EOF as failure - Read loops expect EOF. Fix: Branch on
errors.Is(err, io.EOF)separately from real errors.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Custom struct + errors.As | Callers need fields (field name, retryable) | Only a yes/no branch is needed |
| Error codes in one type | Many related variants share shape | Each variant is truly independent |
| Opaque errors | Hiding implementation from callers | Callers must branch on specific causes |
fmt.Errorf without %w | Inner cause must not be inspectable | Callers need errors.Is through the chain |
FAQs
What is a sentinel error?
A package-level var ErrX = errors.New("...") value with stable identity. Callers recognize it with errors.Is.
Why does err == ErrNotFound fail after wrapping?
Wrapping creates a new error value whose direct comparison target is the wrapper, not the sentinel. errors.Is traverses Unwrap.
Should sentinels be exported?
Export only errors that are part of your documented API. Keep internal failures unexported or opaque.
How do I name sentinel errors?
Use Err prefix and PascalCase: ErrNotFound, ErrConflict. Match Go standard library style.
Can I use errors.Is with io.EOF?
Yes. errors.Is(err, io.EOF) is the idiomatic check in read loops and works through wraps.
What is the difference between os.IsNotExist and errors.Is?
os.IsNotExist is a helper that checks os.ErrNotExist (and wraps) on any error. errors.Is is the general mechanism.
Should libraries return sentinels or formatted strings?
Return sentinels (or typed errors) for contract outcomes; wrap with context using fmt.Errorf and %w at each layer.
How many sentinels is too many?
If you need a table to explain them, consider a typed error with a code field or separate types per category.
Does errors.Is work with errors.Join?
Yes. errors.Is checks each error in a joined value (Go 1.20+).
When should I use %v instead of %w?
When the wrapped error should not be visible to Is/As, such as sanitizing messages at a public API boundary.
Is io.EOF really an error?
It is an error value used for control flow at end of input. Handle it explicitly rather than logging it as a failure.
How do sentinels interact with HTTP status mapping?
Map with errors.Is in handlers: not found to 404, conflict to 409. Keep mapping at the transport boundary.
Related
- Errors as Values: Go's Error Philosophy - why explicit errors exist
- Error Wrapping with %w & errors.As - chains and typed inspection
- Custom Error Types & Error Interfaces - when sentinels are not enough
- API Error Design for HTTP and gRPC Services - mapping to status codes
- Error Handling Best Practices - team conventions
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).