Skip to content
gohacks.dev
Basics

Sentinel Errors in Go and Why They Create Tight Coupling

Go sentinel errors such as io.EOF are simple and also public API. See how they couple callers, why they are mutable variables, and what to use instead.

6 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

io.EOF is the most successful error in Go. A reader returns it to say "no more data," a loop tests for it, and the design is so plain that most programmers write their own along the same lines: var ErrNotFound = errors.New("not found"), exported from a package, and compared in the caller. That is a sentinel error, named for the guard value that marks an end. The pattern has real merits and a set of costs that tend to show up a year after the code is written, when someone wants to change it.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article lays out both sides with code that runs. It is part of the Go error handling guide and a companion to error design, which compares sentinels with the alternatives. Everything ran on Go 1.25.6.

What a sentinel is

A sentinel is an error value created once at package level and shared:

main.goGo
package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

type Store struct{ data map[string]string }

func (s *Store) Get(key string) (string, error) {
	v, ok := s.data[key]
	if !ok {
		return "", fmt.Errorf("get %q: %w", key, ErrNotFound)
	}
	return v, nil
}

func main() {
	s := &Store{data: map[string]string{"a": "1"}}
	_, err := s.Get("b")
	if errors.Is(err, ErrNotFound) {
		fmt.Println("missing:", err)
	}
}
Text
missing: get "b": not found

The convention is the Err prefix, a lower-case message with no punctuation, and a doc comment saying when the value is returned. The standard library has dozens: io.EOF, io.ErrUnexpectedEOF, os.ErrNotExist, fs.ErrPermission, sql.ErrNoRows, context.Canceled, http.ErrServerClosed. Callers test them with errors.Is, which sees through any wrapping.

What is good about them

They are the simplest thing that works. No type to declare, no methods to write, and a caller needs one line to branch on the condition. They carry a stable name that shows up in documentation and in search. For a condition that is a state and not an event with details (end of input, nothing found, the thing is closed), a bare value is the right size. They are also cheap: comparing two interface values by identity does not allocate.

For those reasons the standard library uses them freely, and for a short list of well-understood conditions you should too.

The costs

Callers must import you

To write errors.Is(err, store.ErrNotFound), the calling package imports store. In the common case it already does, so this is free. Sentinels become expensive when the code that makes a decision should not depend on the package that produced the error. A handler that maps errors to status codes sits two layers above the database package, and if it has to import that package to name its sentinels, a layering rule is broken. The handler learns about storage, and storage cannot change without updating the handler. Wrapping across layers shows how this plays out in a service.

Each one is public API forever

An exported sentinel is a promise. Callers will compare to it, so you cannot return a different sentinel for the same condition, or stop returning it, without breaking them. If ErrNotFound later needs to distinguish "no such row" from "no such table", you have two choices: add ErrNoSuchTable and tell every caller to check both, or leave a vague error in place. The Go compatibility promise covers the standard library, and your package has the same obligation to its users in miniature.

They are variables

Go has no constant errors. errors.New returns a pointer, and the variable holding it is assignable by anyone who can reach it, including other packages:

main.goGo
package main

import (
	"errors"
	"fmt"
)

var ErrBusy = errors.New("busy")

func work() error { return fmt.Errorf("work: %w", ErrBusy) }

func main() {
	err := work()
	fmt.Println(errors.Is(err, ErrBusy))

	saved := ErrBusy
	ErrBusy = errors.New("busy")
	fmt.Println(errors.Is(err, ErrBusy), errors.Is(err, saved))
}
Text
true
false true

Reassigning ErrBusy to a new error with the same message breaks every comparison, because the old value in flight no longer equals the variable. In one file this is a contrived demonstration. Across packages it needs a careless ErrBusy = ... in an init or a test, and a test that replaces a sentinel and forgets to restore it can poison the next test in the same process. Nothing in the language prevents it. The standard library accepts the risk for a small set of values that no sensible code reassigns.

Two errors with one message are different errors

Because identity decides, a duplicate sentinel in another package never matches:

main.goGo
package main

import (
	"errors"
	"fmt"
)

var (
	ErrNotFoundA = errors.New("not found")
	ErrNotFoundB = errors.New("not found")
)

func main() {
	fmt.Println(ErrNotFoundA == ErrNotFoundB, errors.Is(ErrNotFoundA, ErrNotFoundB))
}
Text
false false

This is false, even though both print the same text. In a codebase with a repo.ErrNotFound, a cache.ErrNotFound and a service.ErrNotFound, a caller has to know which one it was handed, and the natural fix, converting each at the boundary, is boilerplate that the sentinel was meant to avoid.

No data, so callers parse strings

The deepest problem is what happens when a caller needs more than the identity. A sentinel ErrInvalid says something was invalid, not which field. The caller's next step is strings.Contains(err.Error(), "email"), and now the message text is API, which is worse than any sentinel. At that point you want a type with fields (custom error types).

A safer shape: constant errors

If the mutability bothers you, a small named string type gives you an error that is immutable and comparable:

main.goGo
package main

import (
	"errors"
	"fmt"
)

type constError string

func (e constError) Error() string { return string(e) }

const ErrNotFound = constError("not found")

func find() error { return fmt.Errorf("find: %w", ErrNotFound) }

func main() {
	err := find()
	fmt.Println(errors.Is(err, ErrNotFound))
	fmt.Println(ErrNotFound == constError("not found"))
}
Text
true
true

It is a real constant: nobody can reassign it. It compares by value, so errors.Is works and two packages' constants never collide, since the type constError is declared per package. The cost is that equality is by text, so inside the defining package a typo makes a silent new value, and the pattern is unfamiliar to many readers. A few well-known projects use it, and the standard library does not. It is worth knowing about, though I would reach for it only when the mutability worry is real.

When a sentinel is the right choice

Use a sentinel when all of these hold.

  • The condition is a state, and the caller needs only to know it happened.
  • The set is small and closed: three or four per package, not twenty.
  • The meaning is stable. io.EOF means the same thing in every reader, and nobody needs to extend it.
  • Callers are expected to branch on it, so it belongs in the documentation as a first-class outcome.

Then treat it as API. Document it on the function that returns it. Always return it wrapped with %w or bare, never flattened with %v. Tell callers to use errors.Is. Add a test that holds the line, as in testing error paths.

What to use instead

When the facts do not hold, there are two other styles. A custom type carries data and lets callers pull fields out with errors.As. A behavior check, such as an interface with a Temporary() bool or NotFound() bool method, tells callers what they may do without telling them what package the error came from. The second is the loosest coupling available, and it is how the standard library's net.Error works. Error types, sentinels and opaque errors compares all three on one example, and the Go blog's Working with Errors in Go 1.13 explains the thinking behind Is and As and when each is appropriate. For the reference to the standard sentinels in context, see io.EOF and the fs package errors.

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.