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.

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:
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)
}
}missing: get "b": not foundThe 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:
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))
}true
false trueReassigning 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:
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))
}false falseThis 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:
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"))
}true
trueIt 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.EOFmeans 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.


