Error Types Sentinel Values or Opaque Errors in Go
Compare the three ways a Go package can report failure, sentinel values, error types and opaque behavior checks, with one example, a table and a rule.

Every package that can fail has to decide what its failures look like to its callers. The decision is easy to make without noticing: one function returns errors.New("not found"), another returns a struct, a third wraps something from a dependency, and by the tenth function the package has ten styles. A caller who wants to react to a failure has to read the source of each to find out how.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Go gives you three coherent styles and no rule for choosing between them. This article sets the three side by side on one example so you can see what each costs the caller and the author, then gives a rule for choosing. It belongs to the error handling guide, and the individual styles have articles of their own: sentinel errors and custom error types. Everything ran on Go 1.25.6.
One problem, three designs
Take a small key-value store with a Get method that can fail because the key does not exist. A caller wants to answer a simple question: was it missing? Here are three ways to let it ask.
1. A sentinel value
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("store: key not found")
type Store map[string]string
func (s Store) Get(k string) (string, error) {
v, ok := s[k]
if !ok {
return "", fmt.Errorf("get %q: %w", k, ErrNotFound)
}
return v, nil
}
func main() {
_, err := Store{}.Get("x")
fmt.Println(errors.Is(err, ErrNotFound), err)
}true get "x": store: key not foundThe caller writes errors.Is(err, store.ErrNotFound). It is one line to produce and one to consume. The package exports a variable and the caller must import it.
2. A custom type
package main
import (
"errors"
"fmt"
)
type NotFoundError struct{ Key string }
func (e *NotFoundError) Error() string { return fmt.Sprintf("store: key %q not found", e.Key) }
type Store map[string]string
func (s Store) Get(k string) (string, error) {
v, ok := s[k]
if !ok {
return "", &NotFoundError{Key: k}
}
return v, nil
}
func main() {
_, err := Store{}.Get("x")
var nf *NotFoundError
if errors.As(err, &nf) {
fmt.Println("missing key:", nf.Key)
}
}missing key: xThe caller writes errors.As(err, &nf) and gets the key back as data. The package exports a type and a field, and the caller must import it. Anything that needs more than the fact of the failure, such as the key, a path or a retry delay, wants this form.
3. Opaque, with a behavior check
package main
import (
"errors"
"fmt"
)
type notFound struct{ key string }
func (e notFound) Error() string { return fmt.Sprintf("store: key %q not found", e.key) }
func (e notFound) NotFound() bool { return true }
// IsNotFound reports whether err, or any error it wraps, means a key was missing.
func IsNotFound(err error) bool {
var nf interface{ NotFound() bool }
return errors.As(err, &nf) && nf.NotFound()
}
type Store map[string]string
func (s Store) Get(k string) (string, error) {
v, ok := s[k]
if !ok {
return "", fmt.Errorf("get: %w", notFound{k})
}
return v, nil
}
func main() {
_, err := Store{}.Get("x")
fmt.Println(IsNotFound(err), IsNotFound(errors.New("other")))
}true falseThe caller writes store.IsNotFound(err). The package exports one function and nothing else: the concrete type is unexported and the interface is anonymous. A caller who wants more than that is out of luck, which is the point. Nobody can come to depend on the type.
What each costs
| Sentinel | Custom type | Opaque with helper | |
|---|---|---|---|
| Caller code | errors.Is(err, pkg.ErrX) | errors.As(err, &x) | pkg.IsX(err) |
| Carries data | No | Yes | No |
| Exported surface | A variable | A type and its fields | A function |
| Caller imports the package | Yes | Yes | Yes, for the helper |
| Can change implementation later | Hard | Hard | Easy |
| Works across packages by behavior | No | No | Yes, with a shared interface |
| Standard library example | io.EOF, sql.ErrNoRows | *fs.PathError, *json.SyntaxError | net.Error with Timeout() |
Note the middle row: with an opaque style the package can swap a sentinel for a type, or the reverse, without breaking anyone, because the callers see only the helper. That freedom is worth a good deal in a package that will live for years. A few large Go projects have gone this way deliberately. Kubernetes exposes helpers such as IsNotFound and friends rather than asking callers to inspect its error type, and Docker's errdefs package describes a set of error interfaces that packages implement to communicate classes of errors. In each case the callers ask a yes-or-no question and do not learn the shape of the error.
What the callers actually need
Before choosing, look at what callers do with the failure. In practice they do one of four things, and the style follows from which.
- Nothing but report it. Log it or return it wrapped. Any style works, and the simplest is
fmt.Errorfwith no exported name at all. - Branch on a condition. Retry, return 404, fall back to a default. This needs a question with a yes-or-no answer. Sentinels,
Is-style helpers and behavior interfaces are all fine. - Read details. Show the field name, wait the suggested number of seconds, include the path in a message. This needs data, and so a type.
- Translate it to another domain. Convert a storage error into an application error. This is the case that argues hardest for opaque: the translation layer should depend on the narrowest possible interface, because it sits between two layers that should not know each other (domain errors).
Most real code sits in the second group. A handler wants to know "missing, invalid, or something else". It almost never wants the exact type of a database driver's error, and when it asks for one, it has been handed more than it should.
A decision procedure
Start from the opaque end and move toward the others only when you have a reason.
- Return a plain wrapped error if no caller will branch on it. Don't export anything.
- Add a helper or a one-method interface if callers will branch on a class of failure. Tell them to use it. Keep the implementation unexported so it can change.
- Export a type when callers need data. Keep its fields to what they use. Give it an
Unwrapmethod if it carries a cause. - Export a sentinel only for a small, permanent, data-free condition that a caller of this function will check as a normal outcome.
io.EOFis the model. The condition should be one you would be comfortable documenting for ten years.
If you find yourself defining a sentinel and a type for the same failure, as a belt-and-braces measure, stop: that doubles the surface for no new capability. Pick one.
Consistency within a package
The worst design is the one that varies. If Get returns a sentinel, Delete returns a type and List returns a bare string, callers cannot form a habit. Choose a style per package and document it once at the top of the package comment, then hold to it. A short statement does the job: "Methods return errors for which IsNotFound, IsConflict and IsTimeout report true; no other error values are part of the API."
Document it where it will be read. The doc comment on each function should name the conditions it can report, in terms of the style you chose (doc comments). A function that says only "returns an error on failure" has told callers nothing they can code against.
Changing your mind later
Design mistakes are cheap to fix when the surface is small and expensive when it is wide, which is the practical argument for starting narrow. Even so, a package sometimes starts with a sentinel and later needs data. The migration is possible without breaking callers if the new type answers to the old name. Give the type an Is method that matches the sentinel:
package main
import (
"errors"
"fmt"
)
// ErrNotFound is kept for callers written against the first version.
var ErrNotFound = errors.New("store: key not found")
type NotFoundError struct{ Key string }
func (e *NotFoundError) Error() string { return fmt.Sprintf("store: key %q not found", e.Key) }
func (e *NotFoundError) Is(target error) bool { return target == ErrNotFound }
func main() {
err := fmt.Errorf("get: %w", &NotFoundError{Key: "x"})
fmt.Println("old caller:", errors.Is(err, ErrNotFound))
var nf *NotFoundError
fmt.Println("new caller:", errors.As(err, &nf), nf.Key)
}old caller: true
new caller: true xOld callers keep checking errors.Is(err, ErrNotFound) and it still returns true, and new callers use errors.As and read the key. Mark the sentinel with a Deprecated: line in its doc comment so linters point people at the type. The reverse move, from a type to a sentinel, is rarely needed. The cost of this migration, one Is method, is small, but it only exists because the sentinel was kept exported. A package that exported only IsNotFound from day one would have changed its internals with no ceremony and no compatibility shim. That is the lasting argument for the opaque form.
Testing the contract
Whatever you pick, test through the public form, not through the implementation, so you are free to change it. For the opaque style that means IsNotFound(err), not a type assertion on the unexported struct. Testing error paths shows how to arrange a failing dependency and assert on the helper, and the Go blog on error values in 1.13 gives the language team's own guidance on when to expose the underlying error and when not to.


