Last Updated: May 22, 2026
A sentinel error is a single, named error value that a package exposes so callers can recognize a specific condition and react to it. Instead of inspecting an error message string (which is brittle), the caller compares against the sentinel and branches on the result. This lesson covers how sentinels are declared, why their identity is what makes the comparison reliable, how to compare them safely with errors.Is, and when a sentinel is the right choice versus a custom type or just an unnamed error.
The standard library is full of these. io.EOF tells a reader's caller that the stream has ended. sql.ErrNoRows tells a database caller that the query returned no rows. os.ErrNotExist tells a filesystem caller that the path doesn't exist. They all share the same shape: a package-level var whose value is a single error, exported so callers can refer to it by name.
ErrOutOfStock is declared once at the package level. Every call site that returns "this product has no stock left" returns that same value. Every caller that wants to react to "out of stock" checks for it with errors.Is. The name is the API contract: as long as callers refer to ErrOutOfStock, they can keep working even if the message string changes.
The word "sentinel" comes from how the value is used: a known marker the caller watches for, like a sentry at a gate. The error doesn't carry extra data, doesn't change between calls, and isn't constructed fresh each time. It's a constant marker for a named condition.
errors.New at Package LevelSentinels are almost always declared with errors.New and assigned to an exported var at the top of a file. The convention is the Err prefix on the name and a lowercase message that describes the condition.
The sentinels are grouped in a var (...) block, which is the usual style when a package exports more than one. The names start with Err, which is the Go convention for any exported error value. The message strings are lowercase and don't end with punctuation, which is the convention for error messages from the standard library and from the Go style guide.
errors.New is being used to produce one error value at startup, which the rest of the program then reuses. Nothing about that call is interesting on its own; what's interesting is that the value lives at package scope, so every caller in the program (and every external package that imports yours) refers to the same value through the same name.
A common alternative is fmt.Errorf for the declaration, but it's rarely useful at package level. fmt.Errorf exists to interpolate runtime values into a message, and a sentinel has no runtime values; it's a fixed marker. Stick with errors.New for sentinels.
a and b both refer to the same underlying error value, and the equality check confirms that. Identity is the whole point, which is why the next section dwells on it.
A few naming rules show up in nearly every Go codebase, public or private:
| Pattern | Example | Notes |
|---|---|---|
Err prefix | ErrNotFound, ErrOutOfStock | Standard for exported sentinels |
err prefix (lowercase) | errInternalLookup | Used for unexported, package-private sentinels |
No Error suffix | ErrNotFound, not NotFoundError | The Error suffix is reserved for custom error types |
| Lowercase message | errors.New("out of stock") | No leading capital, no trailing period |
| Short, condition-focused names | ErrCartEmpty, not ErrTheUserHasAnEmptyCart | Names describe the condition, not a sentence |
The split between Err (exported, capital E) and err (unexported, lowercase e) follows Go's general rule for visibility: capital first letter means exported, lowercase means package-private. It applies to error sentinels the same way it applies to any other identifier.
ErrOutOfStock is part of the package's public API; any other package that imports this one can write errors.Is(err, mypkg.ErrOutOfStock). errCacheMiss is internal scaffolding, used to signal a condition between functions in the same package. The capitalization choice is the API choice.
The reason to avoid an Error suffix on the variable name is that it collides visually with custom error types, which are typically named NotFoundError or ValidationError and are structs. A NotFoundError you can construct and fill with data is different from an ErrNotFound that's a single fixed value. Keeping the names distinct keeps the two patterns clearly separated.
The reason a sentinel comparison works is that errors.New returns a pointer to a freshly allocated value, and the package-level var holds that pointer. Every call site that returns the sentinel returns the same pointer. The comparison err == ErrOutOfStock is a pointer-equality check, and two references to the same allocated value compare equal.
Here's what errors.New actually does, simplified:
Each call to New allocates a new errorString and returns a fresh pointer to it. The two pointers point at two different objects in memory, so a == b is false even though the message strings are identical. If you tried to compare errors by constructing a new one each time you needed to check "is this the out-of-stock error", you'd never get a match.
The sentinel pattern works because the construction happens exactly once, at package init time:
Both functions return the value held by the package-level variable. Internally, the error interface stores a (type, value) pair where the value is a pointer to the errorString. Both calls return the same pointer, so the comparison succeeds.
The diagram shows the single errorString object that lives behind ErrOutOfStock. Both reserveA and reserveB return the same package-level variable, which holds a pointer to that one object. The equality check compares the two pointers, finds them equal, and returns true. If either function constructed a fresh error with errors.New(...) inside the function body, the comparison would fail because each call would allocate a new object.
This is the practical reason sentinels are declared at package scope and constructed exactly once. Construction at every call site would defeat the whole pattern.
Cost: comparing an error against a sentinel is one pointer comparison if neither side is wrapped, or a short loop through the wrap chain if errors.Is is doing the walk. Either way it's cheap. The cost concern is correctness (use errors.Is so wrapping doesn't break the match), not performance.
errors.Is vs ==There are two ways to check "is this error the out-of-stock sentinel": direct equality with ==, and the errors.Is function. They give the same answer when the error hasn't been wrapped. When wrapping is involved, only errors.Is works.
When the error is returned unwrapped, both checks succeed. Now wrap the error with context, as you'd do when a higher-level function annotates the lower-level error:
The checkout function wraps the underlying error so the message carries extra context. After the wrap, the returned error is no longer the same value as ErrOutOfStock; it's a new *fmt.wrapError whose inner error is the sentinel. Direct comparison with == returns false because the pointers are different. errors.Is walks the wrap chain and matches the sentinel inside, so it returns true.
errors.Is is the right comparison to use by default. Use it even when you don't think any wrapping is happening, because:
The diagram shows how errors.Is walks the wrap chain. At each step it compares the current error against the target sentinel. If it matches, the call returns true. If it doesn't, the function asks whether the current error has an Unwrap method that exposes an inner error, and if so, repeats the check on that inner error. The walk stops when either a match is found or the chain runs out without one.
The direct equality check has a place: in code you fully control, where you know nothing is wrapping the error, err == ErrOutOfStock is slightly clearer and arguably faster. In practice, most Go developers use errors.Is everywhere for consistency. Reserving == for cases where you absolutely do not want wrap-chain matching is the rare exception.
Once a sentinel is exported, it becomes part of the package's API contract. Callers may write code that branches on it, and breaking the contract breaks their code. There are three ways to break it, in order from most obvious to most subtle:
| Change | Caller impact |
|---|---|
| Renaming the variable | Compile error: caller's reference no longer resolves |
| Deleting the variable | Compile error: same as above |
Reassigning to a new errors.New call | Silent breakage: caller's errors.Is checks stop matching, but compile succeeds |
Renaming and deleting are loud breaks. The compiler catches them at build time. Reassignment is the dangerous one because nothing catches it at compile time. If a maintainer changes the line to use fmt.Errorf and reassigns:
then every caller in every other package that uses errors.Is(err, ErrOutOfStock) keeps compiling, but the comparison silently stops matching, because fmt.Errorf returns a fresh pointer that's not the value the rest of the codebase has been returning. Production bugs of this shape are quiet and hard to diagnose because nothing fails loudly.
This works because the sentinel is stable. If a future maintainer were to rewrite reserve to return errors.New("out of stock") directly instead of returning the package variable, the caller's errors.Is check would silently start returning false. The message would still be "out of stock", but the identity would be different.
The defensive rule that follows from all of this is simple. Sentinels should be:
A reasonable habit is to put all of a package's sentinels in a single errors.go file near the top of the package, so they're easy to find and easy to review for accidental changes.
The grouped declaration makes the package's public error contract visible at a glance. A reviewer looking at a pull request that touches this file knows immediately to think about API compatibility. A reviewer looking at a sentinel scattered somewhere else in the package may not.
Cost: the runtime cost of a sentinel is one allocation at program startup. The actual cost to watch for is the API rigidity: every exported sentinel is a long-term commitment. Don't export one unless callers will genuinely benefit from branching on it.
The sentinel pattern and the custom-type pattern solve overlapping problems, but they make different trade-offs.
| Aspect | Sentinel | Custom type |
|---|---|---|
| Carries extra data | No | Yes (fields on the struct) |
| Compared with | errors.Is | errors.As (often) or errors.Is |
| Declaration | One var | A struct type plus a constructor |
| Best for | Conditions with no extra context | Conditions with structured context |
| API surface | One variable | A type plus its fields and methods |
A sentinel says "this thing happened" and nothing more. ErrOutOfStock doesn't tell you which product or what quantity was requested; it's only a marker. A custom error type can carry that context:
The sentinel version is simpler: one variable, one check, one message. The custom-type version is more informative: the caller can extract the product code and the actual quantities and decide what to do (suggest a smaller quantity, log the discrepancy, surface it to the user). The trade is simplicity versus expressiveness.
A reasonable rule of thumb:
io.EOF is the archetype: the caller just needs to know "the stream ended", and the rest of the context is irrelevant.You can also mix the two. A common shape is a sentinel that callers compare against, plus a custom type for the same family of errors when extra context is available. The custom type implements Is so it still matches the sentinel:
The function returns a structured *OutOfStockError, but the type's Is method tells errors.Is that it should also match the ErrOutOfStock sentinel. Callers who only care about the condition use errors.Is. Callers who want the structured data use errors.As. Both work without contradiction.
Sentinels and custom types aren't mutually exclusive. A package can offer both.
The Go standard library exposes a small set of sentinels that come up regularly in application code. Knowing the names saves you from searching the docs for "how do I detect X" every time.
| Sentinel | Package | Meaning |
|---|---|---|
io.EOF | io | The reader has reached the end of the input |
io.ErrUnexpectedEOF | io | The reader hit end-of-input mid-record, when more data was expected |
io.ErrShortWrite | io | A Write returned fewer bytes than requested without an explicit error |
sql.ErrNoRows | database/sql | A QueryRow found no matching row |
sql.ErrTxDone | database/sql | An operation was tried on a transaction that's already committed or rolled back |
os.ErrNotExist | os | The file or directory doesn't exist |
os.ErrExist | os | The file or directory already exists |
os.ErrPermission | os | The operation was denied because of insufficient permissions |
os.ErrClosed | os | The file was already closed |
context.Canceled | context | The context was canceled by a caller |
context.DeadlineExceeded | context | The context's deadline passed |
http.ErrNoLocation | net/http | Response.Location was called on a response with no Location header |
http.ErrServerClosed | net/http | Server.Serve returned because the server was shut down |
bytes.ErrTooLarge | bytes | A bytes.Buffer can't grow because the data is too large |
A typical use looks like this. Suppose you're reading a customer record from a database, and "no such customer" is a normal, expected outcome that the rest of your code should handle gracefully:
sql.ErrNoRows is the standard way to detect "the row wasn't there". The switch statement uses errors.Is to branch on the sentinel first, then falls through to a generic error path, then to the success case. This three-way split is a common shape in real Go code: known condition first, unknown condition second, success last.
io.EOF has the same flavor but applies to streams. A Read that returns io.EOF isn't reporting a failure; it's reporting that the data has ended. Code that loops over a stream looks like this:
The loop reads up to 8 bytes at a time. After the last chunk, the reader returns io.EOF, which the loop recognizes through errors.Is and exits cleanly. The Go convention is that io.EOF is not an error in the failure sense, just a signal that the data is done. Treating it like a normal sentinel keeps the calling code simple.
context.Canceled and context.DeadlineExceeded come up in any code that uses context.Context for cancellation. Both are sentinels, and both are detected with errors.Is in the same way. The same comparison pattern works across all of these.
A sentinel is one option among several for signaling a condition through an error. The other options are:
errors.New or fmt.Errorf, when the caller doesn't need to branch on the condition specifically.value, ok := m[key] style for maps).The decision tree looks like this:
The decision tree shows the two main forks. The first asks whether any caller will branch on the specific condition: if no one will write if errors.Is(err, Something), there's no reason to commit to a named value, and a plain error message is enough. The second fork only matters when callers do branch on the condition: if the caller's reaction depends on data attached to the error, a custom type fits; if the caller only needs to recognize the condition, a sentinel is simpler.
Run that filter on a few realistic E-Commerce examples:
| Condition | Caller needs to branch? | Caller needs extra data? | Pattern |
|---|---|---|---|
| Out of stock | Yes (show "sold out" message) | No (the message is the same regardless of the product) | Sentinel: ErrOutOfStock |
| Validation failed on form fields | Yes (show errors next to each field) | Yes (list of field names and reasons) | Custom type with a []FieldError slice |
| Database lookup found no row | Yes (return a 404 to the client) | No | Sentinel: sql.ErrNoRows |
| Payment declined | Yes (retry or surface to user) | Yes (decline reason code) | Custom type with a reason field |
| File doesn't exist | Yes (create it or report) | Yes (path that failed, often through wrap context) | Sentinel: os.ErrNotExist, plus wrapping for the path |
| Generic internal database failure | No (the caller just propagates it up) | No | Plain fmt.Errorf("db: ...") |
The pattern that recurs is "yes/no" for sentinel, "yes/yes" for custom type, "no/anything" for plain error. The sentinel sweet spot is "condition matters, data doesn't".
A subtlety worth mentioning: os.ErrNotExist is a sentinel that's almost always returned wrapped, because the OS-level error carries the path. The standard library types implement Is so the wrapped error still matches the sentinel via errors.Is. You don't unwrap it manually; you just compare:
The error returned by os.Open is a *fs.PathError (a custom type that carries the path and the operation that failed), not the bare os.ErrNotExist. But *fs.PathError implements Is to match os.ErrNotExist when its inner error is that sentinel, so the errors.Is check still works. This is the canonical example of the "custom type plus sentinel" combination: a rich error type that wraps a sentinel and forwards identity checks through Is.
Every exported sentinel commits the package to keeping that variable forever, with that name and that identity. A package that exports a dozen sentinels has a dozen long-term API commitments, each one a potential breaking change. The temptation, especially in the first month of a new package, is to invent a sentinel for every condition the package can produce. Resist it.
Sentinels are right when:
Sentinels are wrong when:
An E-Commerce example of the anti-pattern: a cart package that exports way too many sentinels:
With this many sentinels, it's hard to keep track of which one says what, and most of them will never get used by a caller. Many of these would be more honestly modeled as either:
ErrInvalidCart sentinel with a wrapped error carrying details.CartError type with a reason code field.fmt.Errorf with no sentinel at all.A more disciplined version of the package might keep two or three sentinels and use fmt.Errorf for the rest:
The "too many items" and "too heavy" cases don't get their own sentinels because no caller is going to branch differently based on those specific conditions; they're both "the cart is invalid, show the user the message". A single fall-through case handles both. Only ErrCartEmpty gets the named-value treatment because it's the one condition where the caller might want to react differently (prompt the user to add items, rather than reject a malformed cart).
The discipline is to ask, for each potential sentinel, "will any caller actually write errors.Is(err, ThisOne)?" If you can't name a specific caller and a specific reaction, the sentinel doesn't belong in the API.