Sentinel error pattern

The sentinel error pattern defines a unique error variable to identify specific failure conditions for precise error handling.

The problem with string matching

You write a function that reads a user profile from a database. It fails, so it returns an error. The caller catches the error and checks if the message contains the words "not found". It works perfectly for three months. Then a library update changes the error message to "user record not found: id 42". Your string check fails. The program crashes or falls back to a default behavior that makes no sense.

This happens because error messages are human readable, not machine readable. They change when developers rephrase them, translate them, or add context. Relying on text matching for control flow is fragile. Go solves this by treating errors as first-class values with stable identities.

What a sentinel error actually is

A sentinel error is a pre-declared variable that holds a specific error value. The word sentinel comes from a guard posted at a boundary. In programming, it marks a known condition that your code can recognize reliably. Instead of comparing text, you compare the error variable itself.

Think of it like a specific ticket number at a service desk. The desk might change how they describe the issue on the ticket, but the ticket number stays the same. Your code checks the ticket number, not the description. The number is stable. The description is not.

In Go, you declare these at the package level. They live in memory for the entire run of the program. Every function in that package returns the exact same variable when the condition occurs. Callers check against that exact variable. The Go community treats if err != nil { return err } as standard boilerplate. The verbosity is intentional. It forces you to acknowledge the failure path instead of hiding it behind silent returns or exceptions.

The minimal pattern

Here is the simplest way to define and check a sentinel error. The package declares the error once, returns it when the condition triggers, and the caller verifies it with errors.Is.

package storage

import "errors"

// ErrNotFound signals that a requested item does not exist.
var ErrNotFound = errors.New("item not found")

// GetItem retrieves an item by its identifier.
func GetItem(id string) error {
    // Return the pre-declared variable, not a new error
    if id == "" {
        return ErrNotFound
    }
    // Simulate successful retrieval
    return nil
}

The caller checks the result without parsing strings.

package main

import (
    "errors"
    "fmt"
)

func main() {
    err := storage.GetItem("")
    // errors.Is handles direct returns and wrapped chains
    if errors.Is(err, storage.ErrNotFound) {
        fmt.Println("handled missing item")
    }
}

Package-level variables are the standard place for sentinels. They are exported by capitalizing the name. The convention keeps the error accessible to callers while keeping the definition centralized. Don't scatter error creation across multiple functions. Centralize it.

How the runtime handles it

When the package initializes, errors.New runs exactly once. It allocates a small struct on the heap containing the error message and returns a pointer to it. That pointer gets stored in ErrNotFound. The variable never changes.

When GetItem returns ErrNotFound, it hands back that exact pointer. No new allocation happens. The caller receives the same memory address. errors.Is compares the incoming error against the sentinel. If they point to the same underlying value, it returns true.

This identity check is fast and reliable. It does not care about the text inside the error. It only cares that the caller received the exact variable the package exported. The Go team added errors.Is in version 1.13 to replace direct equality checks. Direct comparison with == works for simple returns, but it breaks the moment you wrap the error. errors.Is handles both direct returns and wrapped chains.

The runtime does not perform reflection or string parsing during the check. It walks a linked list of error values. Each step is a pointer dereference. The operation completes in microseconds regardless of how many layers wrapped the original sentinel. Trust the standard library traversal. Do not reinvent it.

Real-world usage with wrapping

Production code rarely returns bare errors. Functions add context as errors bubble up the call stack. A database layer might return ErrNotFound. The repository layer wraps it with table information. The handler layer wraps it with the request ID. Each layer adds a sentence. The original sentinel stays intact inside the chain.

Here is how wrapping preserves the sentinel identity.

package repository

import (
    "errors"
    "fmt"
)

// ErrUserNotFound is the sentinel for missing users.
var ErrUserNotFound = errors.New("user not found")

// FetchUser queries the database and wraps the result.
func FetchUser(id int) error {
    // Simulate a lower-level call that returns the sentinel
    rawErr := queryDatabase(id)
    if rawErr != nil {
        // %w embeds the original error for errors.Is traversal
        return fmt.Errorf("querying users table: %w", rawErr)
    }
    return nil
}

// queryDatabase is a mock that returns the sentinel directly.
func queryDatabase(id int) error {
    return ErrUserNotFound
}

The caller still finds the original condition.

func handleRequest() {
    err := repository.FetchUser(99)
    // The check succeeds even though the error is wrapped
    if errors.Is(err, repository.ErrUserNotFound) {
        logMissingUser()
    }
}

The %w verb in fmt.Errorf tells the formatter to embed the original error inside a new wrapper struct. errors.Is walks the chain automatically. It peels back each layer until it finds a match or reaches the bottom. You never need to manually unwrap errors. The standard library handles the traversal.

The hidden interface behind errors.Is

The errors.Is function does more than compare pointers. It checks for a specific interface method. If an error value implements Is(error) bool, errors.Is calls that method instead of doing a direct comparison. This allows custom error types to define their own matching logic.

A package can export a TimeoutError struct that carries a duration field. The struct implements Is(target error) bool to return true when target matches the package's ErrTimeout sentinel. Callers use errors.Is(err, ErrTimeout) without knowing the underlying type. The interface method bridges the gap between custom structs and sentinel checks.

This design keeps the public API clean. Callers only import the sentinel variable. They do not need to import the custom struct unless they want to extract the duration. The sentinel handles the boolean check. The struct handles the data extraction. Separate the concerns.

Common traps and compiler behavior

The most frequent mistake is recreating the sentinel instead of returning the declared variable. If a function calls errors.New("user not found") inside its body, it allocates a brand new error every single time. The caller checks against the package-level ErrUserNotFound. The two pointers differ. errors.Is returns false. The check fails silently.

The compiler will not stop you from calling errors.New repeatedly. It treats each call as a valid function invocation. You only notice the bug when your error handling logic stops triggering. Always return the exported variable. Never reconstruct the message inline.

Another trap is mixing == with wrapped errors. If you compare a wrapped error directly to a sentinel using ==, the comparison fails because the wrapper is a different type and points to different memory. The compiler accepts the syntax without complaint. The runtime simply returns false. Stick to errors.Is for every error check. It covers direct returns, wrapped chains, and custom error types that implement the Is(error) bool method.

You might also run into confusion when grouping errors. Go 1.20 introduced errors.Join to combine multiple errors into a single value. errors.Is automatically checks every error in the joined group. If any of them match the sentinel, the check returns true. You do not need to loop through the group manually. The standard library handles the iteration.

If you accidentally pass a string to errors.Is instead of an error value, the compiler rejects the program with cannot use "not found" (untyped string constant) as error value in argument. The type system catches the mistake before runtime. Rely on it.

When to reach for sentinels

Use a sentinel error when you need a stable, package-level identifier for a specific failure condition. Use a custom error struct when the caller needs to extract structured data from the failure, like a status code or a retry delay. Use fmt.Errorf without the %w verb when you want to terminate the error chain and report a final message to the user. Use a plain string comparison only when you are parsing unstructured output from external systems and cannot control the error source.

Sentinels are cheap. Wrapping is safe. Trust the chain.

Where to go next