AlgoMaster Logo

Sentinel Errors

Last Updated: May 22, 2026

Medium Priority
13 min read

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.

What a Sentinel Error Is

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.

Declaring a Sentinel: errors.New at Package Level

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

Naming Conventions

A few naming rules show up in nearly every Go codebase, public or private:

PatternExampleNotes
Err prefixErrNotFound, ErrOutOfStockStandard for exported sentinels
err prefix (lowercase)errInternalLookupUsed for unexported, package-private sentinels
No Error suffixErrNotFound, not NotFoundErrorThe Error suffix is reserved for custom error types
Lowercase messageerrors.New("out of stock")No leading capital, no trailing period
Short, condition-focused namesErrCartEmpty, not ErrTheUserHasAnEmptyCartNames 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.

Why Pointer Identity Makes the Comparison Work

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.

Comparing Sentinels: 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:

  • It still works when the error is unwrapped, so there's no downside.
  • If a caller of your function later adds wrapping, your check keeps working.
  • It's the convention every Go codebase follows; readers expect it.

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.

Sentinels as Part of the Package API

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:

ChangeCaller impact
Renaming the variableCompile error: caller's reference no longer resolves
Deleting the variableCompile error: same as above
Reassigning to a new errors.New callSilent 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:

  • Declared once at the top of a file.
  • Never reassigned after declaration.
  • Treated as immutable from the moment they're committed.

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.

Sentinels vs Custom Error Types

The sentinel pattern and the custom-type pattern solve overlapping problems, but they make different trade-offs.

AspectSentinelCustom type
Carries extra dataNoYes (fields on the struct)
Compared witherrors.Iserrors.As (often) or errors.Is
DeclarationOne varA struct type plus a constructor
Best forConditions with no extra contextConditions with structured context
API surfaceOne variableA 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:

  • Use a sentinel when the condition is binary ("does this happen or not") and the caller's reaction doesn't depend on extra details. io.EOF is the archetype: the caller just needs to know "the stream ended", and the rest of the context is irrelevant.
  • Use a custom type when the caller's reaction depends on data attached to the error (which field failed validation, which key wasn't found, how far the file pointer was, and so on).

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.

Standard Library Sentinels Worth Knowing

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.

SentinelPackageMeaning
io.EOFioThe reader has reached the end of the input
io.ErrUnexpectedEOFioThe reader hit end-of-input mid-record, when more data was expected
io.ErrShortWriteioA Write returned fewer bytes than requested without an explicit error
sql.ErrNoRowsdatabase/sqlA QueryRow found no matching row
sql.ErrTxDonedatabase/sqlAn operation was tried on a transaction that's already committed or rolled back
os.ErrNotExistosThe file or directory doesn't exist
os.ErrExistosThe file or directory already exists
os.ErrPermissionosThe operation was denied because of insufficient permissions
os.ErrClosedosThe file was already closed
context.CanceledcontextThe context was canceled by a caller
context.DeadlineExceededcontextThe context's deadline passed
http.ErrNoLocationnet/httpResponse.Location was called on a response with no Location header
http.ErrServerClosednet/httpServer.Serve returned because the server was shut down
bytes.ErrTooLargebytesA 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.

When to Use a Sentinel vs Something Else

A sentinel is one option among several for signaling a condition through an error. The other options are:

  • A custom error type, when the caller needs extra data.
  • An unwrapped, unnamed error from errors.New or fmt.Errorf, when the caller doesn't need to branch on the condition specifically.
  • Multiple return values alongside an error, when the "not found" path is the normal case and the caller always handles it (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:

ConditionCaller needs to branch?Caller needs extra data?Pattern
Out of stockYes (show "sold out" message)No (the message is the same regardless of the product)Sentinel: ErrOutOfStock
Validation failed on form fieldsYes (show errors next to each field)Yes (list of field names and reasons)Custom type with a []FieldError slice
Database lookup found no rowYes (return a 404 to the client)NoSentinel: sql.ErrNoRows
Payment declinedYes (retry or surface to user)Yes (decline reason code)Custom type with a reason field
File doesn't existYes (create it or report)Yes (path that failed, often through wrap context)Sentinel: os.ErrNotExist, plus wrapping for the path
Generic internal database failureNo (the caller just propagates it up)NoPlain 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.

Over-Using Sentinels: When the Pattern Becomes an Anti-Pattern

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:

  • Callers genuinely need to branch on the condition (and you can show real call sites that do).
  • The condition is broadly meaningful (not a one-off internal detail).
  • The set of sentinels is small enough to remember (a handful, not dozens).

Sentinels are wrong when:

  • The package returns dozens of named errors, most of which no caller ever checks.
  • The errors carry obvious structured data ("validation failed in field X with reason Y") that callers will need.
  • The conditions are internal scaffolding that should never have leaked to callers in the first place.

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:

  • A single ErrInvalidCart sentinel with a wrapped error carrying details.
  • A custom CartError type with a reason code field.
  • Or, for the truly internal ones, just 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.