Skip to content
gohacks.dev
Basics

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.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

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

main.goGo
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)
}
Text
true get "x": store: key not found

The 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

main.goGo
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)
	}
}
Text
missing key: x

The 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

main.goGo
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")))
}
Text
true false

The 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

SentinelCustom typeOpaque with helper
Caller codeerrors.Is(err, pkg.ErrX)errors.As(err, &x)pkg.IsX(err)
Carries dataNoYesNo
Exported surfaceA variableA type and its fieldsA function
Caller imports the packageYesYesYes, for the helper
Can change implementation laterHardHardEasy
Works across packages by behaviorNoNoYes, with a shared interface
Standard library exampleio.EOF, sql.ErrNoRows*fs.PathError, *json.SyntaxErrornet.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.

  1. Nothing but report it. Log it or return it wrapped. Any style works, and the simplest is fmt.Errorf with no exported name at all.
  2. 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.
  3. 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.
  4. 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.

  1. Return a plain wrapped error if no caller will branch on it. Don't export anything.
  2. 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.
  3. Export a type when callers need data. Keep its fields to what they use. Give it an Unwrap method if it carries a cause.
  4. Export a sentinel only for a small, permanent, data-free condition that a caller of this function will check as a normal outcome. io.EOF is 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:

main.goGo
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)
}
Text
old caller: true
new caller: true x

Old 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.