Custom Error Types and Sentinel Errors
Go has no exceptions for ordinary failure — an error is just a value satisfying the one-method
errorinterface. That leaves two complementary ways to define the errors a package exposes: sentinel errors, fixed package-level error values a caller compares against (io.EOF,sql.ErrNoRows), and custom error types, concretestructtypes that carry structured data and that callers inspect by type (*os.PathError,*json.SyntaxError). Since Go 1.13 (September 2019) both are checked through error trees witherrors.Isanderrors.Asrather than raw==and type assertions, so an error stays matchable even after being wrapped (Go 1.13 errors blog).
Mental Model
The question every package author faces is: how does a caller programmatically tell my errors apart? There are exactly two answers, and they differ in what the caller needs to know. A sentinel is an identity — “is this error the not-found error?” — checked with errors.Is. A custom type is a shape — “is this error a *PathError, and if so what is its .Path?” — checked with errors.As. A sentinel carries no data beyond its identity; a custom type carries fields. Choosing between them is choosing whether callers need data about the failure or merely need to recognize it.
flowchart TD Q["Caller needs to react<br/>to a specific failure"] --> D{"Does the caller need<br/>structured DATA about<br/>the failure?"} D -- "no, just identity" --> S["SENTINEL ERROR<br/>var ErrNotFound = errors.New(...)<br/>caller: errors.Is(err, ErrNotFound)"] D -- "yes, fields" --> T["CUSTOM ERROR TYPE<br/>type PathError struct { Path string; Err error }<br/>caller: errors.As(err, &pe)"] S --> W["both survive wrapping:<br/>errors.Is / errors.As walk<br/>the Unwrap() chain"] T --> W style S fill:#d4edda style T fill:#cce5ff
Diagram: the decision between a sentinel value and a custom type. The insight: it hinges entirely on whether the caller needs data — identity-only failures are sentinels, data-bearing failures are types — and either way the Go 1.13+ errors.Is/errors.As machinery makes the match survive wrapping.
Mechanical Walk-through
The error interface
Everything rests on one interface, from the builtin package: type error interface { Error() string } — “the conventional interface for representing an error condition, with the nil value representing no error.” Any type with an Error() string method is an error. Sentinels and custom types are just two strategies for producing values of this interface; the The Error Interface note covers the interface mechanics in depth.
Sentinel errors
A sentinel is a package-level variable, conventionally named Err..., created once with errors.New. The errors docs make the key point: “Each call to errors.New returns a distinct error value even if the text is identical.” So a sentinel must be a shared variable — comparing two independently-created errors.New("not found") values is false. Standard-library examples: io.EOF, sql.ErrNoRows, os.ErrNotExist, context.Canceled.
The caller historically matched with ==. Since Go 1.13 the correct check is errors.Is(err, ErrNotFound), which — per the docs — “reports whether any error in err’s tree matches target” by walking the Unwrap chain. This matters because if the sentinel was wrapped (fmt.Errorf("read config: %w", ErrNotFound)), a plain err == ErrNotFound is false but errors.Is(err, ErrNotFound) is true. A sentinel target “must be comparable” — errors.New values are, since they are pointers.
Custom error types
A custom error type is a concrete type (almost always a struct, used as *T) with an Error() string method, carrying fields the caller can read. The standard library’s archetype is *os.PathError (Op, Path, Err fields) and *json.SyntaxError (Offset). The caller extracts it with errors.As: per the docs, “As finds the first error in err’s tree that matches target, and if one is found, sets target to that error value and returns true.” The target is a pointer to a variable of the error type: var pe *fs.PathError; if errors.As(err, &pe) { use(pe.Path) }. errors.As panics if target is not a non-nil pointer to either a type that implements error or to an interface type (errors docs).
errors.AsType — the generic form (Go 1.26)
Go 1.26 (released August 2026) adds errors.AsType[E error](err error) (E, bool), described by the release notes as “a generic version of As. It is type-safe, faster, and, in most cases, easier to use” (Go 1.26 release notes). The package docs go further: “For most uses, prefer AsType. As is equivalent to AsType but sets its target argument rather than returning the matching error and doesn’t require its target argument to implement error” (errors docs). The ergonomics improve substantially — instead of declaring a var pe *PathError then passing &pe, the caller writes:
if pe, ok := errors.AsType[*fs.PathError](err); ok {
use(pe.Path)
}The match semantics are the same as errors.As: walk err’s tree, on each node check whether its dynamic type can satisfy E (either by direct type assertion err.(E) or via a custom As(any) bool method), and on the first match return the value and true (errors.AsType docs). The type-safety win is that E is a compile-time constraint — there is no longer a runtime panic path for “target is not a non-nil pointer” because the signature itself rules that out. The one capability errors.As still has that AsType does not: AsType’s constraint is [E error], so E must itself implement error; the older As can extract into an interface that is not error (the docs note “doesn’t require its target argument to implement error”). In practice that case is rare — the overwhelming majority of As calls extract a concrete error type and migrate cleanly to AsType.
Wrapping and the Unwrap method
A custom type becomes part of an error chain by implementing Unwrap() error (or Unwrap() []error) returning the error it contains. The Go 1.13 blog gives the canonical shape: type QueryError struct { Query string; Err error } with func (e *QueryError) Unwrap() error { return e.Err }. The simpler route is fmt.Errorf with the %w verb: fmt.Errorf("running query %q: %w", q, err) returns a value whose Unwrap yields the %w argument. errors.Is and errors.As traverse exactly this chain — see Error Wrapping and errors.Is errors.As for the wrapping mechanics; this note is about defining the leaf errors that get wrapped.
Custom Is and As methods
A type can override matching. If it implements Is(error) bool, errors.Is calls it — useful for semantic equality. The Go 1.13 blog’s Upspin example: an Error type whose Is treats an empty field in the target as a wildcard, so errors.Is(err, &Error{User: "someuser"}) matches any error for that user regardless of path. Similarly an As(any) bool method customizes errors.As. These hooks are how a type makes itself match against a family of targets rather than one exact value.
Code Examples
Defining and matching a sentinel
package store
import "errors"
var ErrNotFound = errors.New("store: item not found") // 1 package-level sentinel
func (s *Store) Get(id string) (*Item, error) {
it, ok := s.data[id]
if !ok {
return nil, ErrNotFound // 2 return the shared value
}
return it, nil
}
// caller:
it, err := s.Get("x")
if errors.Is(err, store.ErrNotFound) { // 3 identity check, wrap-safe
// handle missing item
}Line 1 declares the sentinel once. Line 2 returns the same variable every time — not a fresh errors.New. Line 3 uses errors.Is, which works even if some intermediate layer wrapped the error with %w. Naming it ErrNotFound (exported, Err prefix) is the convention so callers can find it.
Defining and inspecting a custom error type
type ValidationError struct { // 1
Field string
Value any
Err error // 2 wrapped cause, optional
}
func (e *ValidationError) Error() string { // 3 satisfies `error`
return fmt.Sprintf("validation failed on %q (value %v): %v",
e.Field, e.Value, e.Err)
}
func (e *ValidationError) Unwrap() error { return e.Err } // 4 joins the chain
// caller:
var ve *ValidationError
if errors.As(err, &ve) { // 5 type + data extraction
log.Printf("bad field: %s", ve.Field) // 6 read structured data
}Line 1 defines a struct carrying the data a caller needs — which field, which value. Line 3’s Error() method makes it satisfy error; it is defined on the pointer receiver, so the error value is *ValidationError. Line 4’s Unwrap lets errors.Is/errors.As see through it to e.Err. Line 5 extracts it: &ve is a **ValidationError; on success ve is set and line 6 reads ve.Field. This is the whole point of a custom type — the caller gets Field, not just a string to grep.
Aggregating many failures with errors.Join
func validateUser(u *User) error {
var errs []error // 1
if u.Name == "" {
errs = append(errs, &ValidationError{Field: "name"})
}
if u.Age < 0 {
errs = append(errs, &ValidationError{Field: "age"})
}
return errors.Join(errs...) // 2 nil if errs empty
}
// caller:
err := validateUser(u)
var ve *ValidationError
if errors.As(err, &ve) { // 3 finds the FIRST one
log.Printf("first bad field: %s", ve.Field)
}Line 1 collects failures rather than returning on the first. Line 2’s errors.Join (Go 1.20, release notes) returns a single error wrapping all of them — and returns nil if every argument was nil, so the no-error path needs no special-casing. The joined value implements Unwrap() []error, so line 3’s errors.As still works, finding the first matching child in depth-first order; errors.Is against any of the children also succeeds. This is the idiomatic way to report multiple validation failures as one error.
A custom Is for semantic matching
type HTTPError struct {
Status int
Msg string
}
func (e *HTTPError) Error() string { return e.Msg }
func (e *HTTPError) Is(target error) bool { // 1 custom match
t, ok := target.(*HTTPError)
if !ok {
return false
}
return e.Status == t.Status // 2 match on status only
}
// caller:
if errors.Is(err, &HTTPError{Status: 404}) { // 3 matches ANY 404
// handle not-found
}Line 1’s Is method overrides default equality. Line 2 declares two HTTPErrors “the same” when their Status matches, ignoring Msg. Line 3 then matches any 404 regardless of message — a family match impossible with a plain sentinel.
Custom type extraction with errors.AsType
var ErrCanceled = errors.New("canceled")
func loadConfig(p string) error {
if _, err := os.Open(p); err != nil {
return fmt.Errorf("loadConfig %q: %w", p, err) // 1 wraps *fs.PathError
}
return nil
}
// caller (Go 1.26+):
err := loadConfig("/etc/missing")
if pe, ok := errors.AsType[*fs.PathError](err); ok { // 2 generic, returns value
log.Printf("file op %s on %s failed: %v", pe.Op, pe.Path, pe.Err)
}
if errors.Is(err, fs.ErrNotExist) { // 3 still works in parallel
// handle not-found
}Line 1 wraps *fs.PathError via %w. Line 2 uses the Go 1.26 generic form: no var pe *fs.PathError declaration, no &pe, no panic path on a bad target — the type parameter [*fs.PathError] is checked at compile time. Line 3 shows the orthogonal errors.Is check: fs.PathError’s Unwrap exposes its inner Err, which errors.Is finds equal to fs.ErrNotExist. The two checks compose because they walk the same tree from different angles — AsType for the shape, Is for the identity inside it.
How errors.Is, errors.As, and errors.AsType actually traverse
All three walk the same error tree. errors.Is(err, target) starts at err and, at each node, returns true if the node equals target or if the node implements Is(error) bool and that method returns true for target; otherwise it descends by calling the node’s Unwrap. If Unwrap returns a single error, the walk is linear; if it returns []error (the errors.Join case), the walk is a depth-first traversal of all children — the package docs describe the order as “pre-order, depth-first traversal” (errors docs). errors.As(err, target) walks identically but matches on assignability: at each node it checks whether the node’s dynamic type is assignable to the type *target points at (or whether the node has a custom As(any) bool); on the first match it assigns and returns true. errors.AsType[E](err) is the same walk with a compile-time type parameter: it checks err.(E) at each node and returns the matching value (per the AsType docs). The practical upshot: a leaf error you define is found by any of the three no matter how deeply it is wrapped, provided every wrapper in the chain implements Unwrap. A wrapper that formats with %v instead of %w does not implement Unwrap — it is an opaque leaf, and the walk stops there. That is the deliberate “hide the cause” mechanism, and also the most common reason a sentinel match unexpectedly fails: somewhere up the chain a layer used %v.
The comparable requirement for sentinel targets
errors.Is’s target “must be comparable” (errors docs) — because the default match is ==. errors.New returns a *errorString pointer, which is comparable, so sentinels are fine. But a sentinel of a struct type with a non-comparable field (a slice, a map, a function) will make errors.Is panic when it reaches a node it tries to == against that target. This is rare but real: if you need a struct-typed sentinel, either keep all fields comparable or give the type a custom Is method so errors.Is never falls back to ==.
Failure Modes and Common Misunderstandings
Re-creating a “sentinel” instead of sharing it. return errors.New("not found") from inside a function does not create a matchable sentinel — every call returns a distinct value, and no caller can errors.Is against it. The sentinel must be a single package-level variable.
Comparing wrapped errors with ==. Once an error is wrapped with %w, err == ErrNotFound is false. Always use errors.Is for sentinels and errors.As for types — they walk the chain.
The errors.As nil-typed-pointer trap. A *ValidationError that is a nil pointer but stored in a non-nil error interface still satisfies errors.As — see Nil Interface vs Nil Pointer. Always check the extracted value, not just the interface.
Over-exposing sentinels makes them API. The Go 1.13 blog stresses: “wrapping an error makes that error part of your API.” The same is true of sentinels and exported error types — once callers depend on errors.Is(err, pkg.ErrFoo), you cannot remove or rename ErrFoo without breaking them. Export errors deliberately; use the unexported %v (non-wrapping) form when you want to hide an implementation-detail cause.
Sentinels vs. types is not either/or. os does both: os.ErrNotExist is a sentinel, *os.PathError is a type, and a *PathError wrapping ErrNotExist matches errors.Is(err, os.ErrNotExist) and errors.As(err, &pe). Use whichever each caller needs.
Pointer vs. value receivers for custom error types
A custom error type is almost always used as a pointer (*ValidationError), and the Error() method is almost always defined on the pointer receiver. There are two reasons. First, identity: if Error() were on a value receiver, two separately-constructed ValidationError values with identical fields would compare == equal — usually not what you want for an error carrying mutable, instance-specific data. With a pointer, each &ValidationError{...} is a distinct identity. Second, errors.As: the target you pass is &ve where ve is *ValidationError, so the chain must contain a *ValidationError for the assignability match to succeed; if your constructor returned a ValidationError value boxed into the error interface, errors.As(err, &ve) with ve of pointer type would never match. The rule of thumb: define custom error types’ Error() (and Unwrap, Is, As) on pointer receivers, construct with &T{...}, and have errors.As targets be pointers-to-pointers. The standard library follows this uniformly — *os.PathError, *net.OpError, *json.SyntaxError are all pointer types.
Sentinels are also a versioning hazard
Because errors.Is(err, pkg.ErrFoo) ties callers to the exact variable pkg.ErrFoo, a sentinel is a more rigid API commitment than it looks. You cannot later split ErrFoo into ErrFooA/ErrFooB without breaking callers who matched the old one; you cannot change what conditions produce it without silently changing caller behavior. A custom type with fields is often the more evolvable choice: you can add fields without breaking errors.As, and callers inspect data rather than depending on identity. This is a real design trade-off — sentinels are simpler and lighter, types are more evolvable and data-rich — and it is worth deciding deliberately rather than reaching for errors.New reflexively.
Alternatives and When to Choose Them
For a failure with no associated data that callers only need to recognize — “EOF”, “not found”, “canceled” — use a sentinel: it is the lightest option and io.EOF is its model. For a failure carrying structured data callers want to read — a path, a line/column, a status code, a validation field — use a custom type with errors.As. When a failure aggregates several errors (e.g. validating many fields at once), use errors.Join (Go 1.20, release notes), which returns an error implementing Unwrap() []error so errors.Is/errors.As still find each child. When the caller needs neither identity nor data and only a human will read it, a plain fmt.Errorf("...") with no %w and no exported type is correct — do not invent a sentinel or type “just in case.” For genuinely unrecoverable conditions, no error value applies — use panic.
Production Notes
The Go standard library is the reference design. Sentinels are used where identity suffices and stability is valuable: io.EOF is checked by virtually every reader loop, sql.ErrNoRows distinguishes “no row” from a real query failure, context.Canceled/context.DeadlineExceeded let callers tell why a context ended. Custom types appear where data matters: *os.PathError, *net.OpError, *json.SyntaxError, *strconv.NumError. A widely-cited cautionary point from the Go 1.13 blog: a package using a database internally should not wrap sql.ErrNoRows into its public errors, because that leaks “we use SQL” into the API contract — return a new package-level sentinel of your own (ErrUserNotFound) instead, decoupling callers from your storage choice.
As of the Go 1.26 baseline (as-of 2026-05-29), the recommended error-handling toolkit is: sentinels for identity, custom types for data, fmt.Errorf with %w for wrapping, errors.Is for identity matching, errors.AsType (preferred over errors.As per the package docs) for type-and-data extraction, and errors.Join (Go 1.20) for multi-error aggregation. Migration of existing errors.As call sites to errors.AsType is straightforward where the target type implements error — which it almost always does — and the new form removes both the boilerplate var pe *T; errors.As(err, &pe) pattern and the panic path on a malformed target.
See Also
- The Error Interface — the one-method interface both strategies satisfy
- Error Wrapping and errors.Is errors.As —
%w,Unwrap, and chain traversal - panic and recover — for unrecoverable failures, not ordinary errors
- Nil Interface vs Nil Pointer — the
errors.Astyped-nil trap - Interface Internals — how an
errorinterface value is represented - Go Internals MOC — parent map