The signal that isn't a crash
You are building a function that fetches a user profile by ID. Most of the time it works. Sometimes the ID doesn't exist. That missing ID is not a bug. It is a normal condition that the caller needs to handle. You could return a string like "user not found" and ask the caller to match it, but string matching is fragile. If you change the message later, the caller breaks. You could panic, but that crashes the program. You need a stable, type-safe signal that says "this specific condition happened."
That signal is a sentinel error. It is a specific error value defined once at the package level and reused everywhere. Callers check for it using the == operator. The comparison is reliable because it relies on memory identity, not text content. Sentinel errors are the standard way to represent expected, recoverable conditions in Go.
What a sentinel error actually is
A sentinel error is a variable of type error that holds a unique error value. You create it using errors.New() and assign it to a package-level variable. The name "sentinel" comes from the idea of a guard or signal at a boundary. When a function returns this value, it is raising a flag that the caller can catch.
The error type is an interface with a single method: Error() string. Interfaces in Go are comparable if the underlying concrete types are comparable. errors.New() returns a pointer to an internal struct. Pointers are comparable. When you store the result in a variable, you are storing a pointer to a unique object. Every time you return that variable, you are returning the same pointer. The == operator compares the pointers. Since they point to the same memory address, the result is true.
This mechanism makes sentinel errors type-safe. The compiler ensures you are comparing an error to an error. You cannot accidentally compare an error to a string or an integer. If you try, the compiler rejects the code with invalid operation: operator == not defined on error.
The minimal pattern
Here is the standard setup. Define the error at the top of the package file. Name it starting with Err followed by the condition in CamelCase. Capitalize the first letter to make it public so callers can access it.
package mypkg
import "errors"
// ErrNotFound signals that a requested item does not exist.
var ErrNotFound = errors.New("item not found")
// FindItem returns the item name or ErrNotFound if the ID is invalid.
func FindItem(id int) (string, error) {
// Negative IDs are invalid in this system.
if id < 0 {
return "", ErrNotFound
}
return "widget", nil
}
Callers check for the sentinel using ==. This pattern is idiomatic and efficient.
package main
import (
"fmt"
"myproject/mypkg"
)
func main() {
// Call the function with an invalid ID.
item, err := mypkg.FindItem(-1)
// Check for the specific sentinel error.
if err == mypkg.ErrNotFound {
fmt.Println("Handled missing item gracefully")
return
}
// Handle unexpected errors separately.
if err != nil {
fmt.Println("Unexpected error:", err)
return
}
fmt.Println("Found:", item)
}
Sentinels are signals. Define them once, check them with ==, and treat them as control flow, not as messages for the user.
Why this works under the hood
The reliability of sentinel errors depends on defining the value exactly once. When you call errors.New("item not found"), the runtime allocates a new object on the heap and returns a pointer to it. If you assign that pointer to a package-level variable, that variable holds the address of that specific object.
When FindItem returns ErrNotFound, it returns the pointer stored in the variable. The caller receives that same pointer. The comparison err == mypkg.ErrNotFound compares the two pointers. They are identical, so the check passes.
If you call errors.New("item not found") inside the function instead of using the variable, you create a new object every time. The caller receives a different pointer. The comparison fails because the pointers are different, even though the error message is identical. This is why you must define the sentinel once and reuse the variable.
Convention aside: Error variables are public by convention so callers can check them. The name starts with Err to indicate it is an error sentinel. The rest of the name describes the condition. ErrNotFound, ErrAlreadyExists, ErrTimeout. This naming pattern is universal in Go codebases.
Realistic usage in a service
In a real application, sentinel errors drive recovery logic. They tell the caller how to proceed. A "not found" error might trigger a cache refill. A "timeout" error might trigger a retry. The sentinel makes the condition explicit so the caller can branch safely.
Here is a cache implementation that uses a sentinel to signal a miss. The caller uses the sentinel to decide whether to fetch from the database.
package cache
import "errors"
// ErrCacheMiss indicates the key is not in the cache.
var ErrCacheMiss = errors.New("cache miss")
// Get retrieves a value from the cache.
func Get(key string) (string, error) {
// Simulate a lookup that fails for "missing".
if key == "missing" {
return "", ErrCacheMiss
}
return "value", nil
}
The caller handles the miss by refetching data. This pattern keeps the cache logic separate from the data source logic.
package main
import (
"fmt"
"myproject/cache"
)
func main() {
// Attempt to get a value that might be missing.
val, err := cache.Get("missing")
// Check for the specific cache miss sentinel.
if err == cache.ErrCacheMiss {
fmt.Println("Cache miss: refetching from database")
// In real code, fetch from DB and update cache here.
val = "fetched from db"
} else if err != nil {
// Handle unexpected errors.
fmt.Println("Error:", err)
return
}
fmt.Println("Value:", val)
}
The if err != nil check is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. Every error must be handled or returned. Sentinel errors fit naturally into this pattern.
Pitfalls and traps
The biggest trap is wrapping. If you wrap a sentinel error with fmt.Errorf("...: %w", ErrNotFound), the == check breaks. The wrapped error is a different type that implements error. It contains the sentinel, but it is not the sentinel. The pointer comparison fails.
When errors might be wrapped, you must use errors.Is. This function checks the error chain for the sentinel. It works whether the error is the sentinel itself or wrapped inside another error.
import "errors"
func wrapper() error {
// Wrap the sentinel with context.
return fmt.Errorf("lookup failed: %w", ErrNotFound)
}
func main() {
err := wrapper()
// This check fails because err is not ErrNotFound.
if err == ErrNotFound {
fmt.Println("This never prints")
}
// This check succeeds because errors.Is unwraps the chain.
if errors.Is(err, ErrNotFound) {
fmt.Println("Found the sentinel in the chain")
}
}
Another trap is creating the error locally. If a function returns errors.New("bad") and the caller checks err == errors.New("bad"), the check is always false. Each call to errors.New creates a new object. The pointers never match. Always define the sentinel at the package level.
Compiler errors catch some mistakes. If you try to compare an error to a string, the compiler rejects it with cannot compare err == "not found" (mismatched types error and untyped string). This prevents fragile string matching. If you forget to import the errors package, you get undefined: errors. If you define a sentinel but never use it, the compiler warns with ErrNotFound declared but not used unless it is exported.
Sentinels are cheap. Custom types are for data. Don't overcomplicate simple signals.
Decision matrix
Use a sentinel error when you need a simple, package-level signal for a known condition like "not found" or "already exists" and callers check it directly with ==.
Use a custom error type when the error carries metadata, such as a status code or a failed ID, and callers need to extract that data using errors.As.
Use errors.Is when errors might be wrapped by intermediate layers and you need to check for the sentinel deep in the chain.
Use errors.As when you need to recover a custom error type from a wrapped error to access its fields.
Use a plain string error when the error is terminal, logged once, and never checked programmatically by the caller.