34. Sentinel errors and `errors.Is`
Printing an error is easy. Reacting to a specific one — retry on a timeout, return 404 on "not found", fail hard on anything else — needs a way to ask "is this that error?" Go's answer is the sentinel error.
Declaring a sentinel
A sentinel is a package-level error variable, named Err...:
package main
import (
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("not found")
ErrDenied = errors.New("permission denied")
)
func fetch(id int) (string, error) {
switch id {
case 1:
return "the record", nil
case 2:
return "", ErrNotFound
default:
return "", ErrDenied
}
}
func main() {
for _, id := range []int{1, 2, 3} {
value, err := fetch(id)
if err == ErrNotFound {
fmt.Println(id, "-> nothing there, using a default")
continue
}
if err != nil {
fmt.Println(id, "-> giving up:", err)
continue
}
fmt.Println(id, "->", value)
}
}
errors.New returns a pointer under the hood, so every call produces a
distinct value. Two errors with identical text are still different errors —
which is exactly what makes err == ErrNotFound meaningful. It's comparing
identity, not text.
You've already used sentinels from the standard library: io.EOF,
sql.ErrNoRows, os.ErrNotExist.
Why == isn't enough
The trouble starts as soon as somebody adds context to the error on the way up — which is what good code does:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func query(id int) error {
return ErrNotFound
}
func loadUser(id int) error {
if err := query(id); err != nil {
return fmt.Errorf("loading user %d: %w", id, err)
}
return nil
}
func main() {
err := loadUser(7)
fmt.Println("message:", err)
fmt.Println("== comparison:", err == ErrNotFound)
fmt.Println("errors.Is: ", errors.Is(err, ErrNotFound))
}
err == ErrNotFound is false: loadUser returned a new error that
contains the sentinel. That's the whole problem, and the %w verb plus
errors.Is is the whole solution.
%w wraps, errors.Is unwraps
fmt.Errorf with %w (instead of %v) produces an error that remembers
what it wrapped. errors.Is walks that chain, comparing at every level:
package main
import (
"errors"
"fmt"
)
var ErrTimeout = errors.New("timeout")
func level3() error { return ErrTimeout }
func level2() error {
if err := level3(); err != nil {
return fmt.Errorf("querying database: %w", err)
}
return nil
}
func level1() error {
if err := level2(); err != nil {
return fmt.Errorf("handling request: %w", err)
}
return nil
}
func main() {
err := level1()
fmt.Println(err)
fmt.Println("is timeout:", errors.Is(err, ErrTimeout))
fmt.Println(errors.Unwrap(err))
fmt.Println(errors.Unwrap(errors.Unwrap(err)))
}
The message reads as a trail — handling request: querying database: timeout
— and the sentinel is still findable at the bottom. Every layer added
context without destroying information.
Always use errors.Is, never ==. Even if nothing wraps that error
today, somebody will add a layer next month, and == will silently start
returning false.
errors.Unwrap peels exactly one layer. You rarely call it directly;
errors.Is is doing it for you in a loop.
Dispatching on the sentinel
The idiomatic way to branch on several sentinels is a switch with no
subject:
package main
import (
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("not found")
ErrDenied = errors.New("permission denied")
ErrRateLimit = errors.New("rate limited")
)
func handle(err error) string {
switch {
case err == nil:
return "200 OK"
case errors.Is(err, ErrNotFound):
return "404 Not Found"
case errors.Is(err, ErrDenied):
return "403 Forbidden"
case errors.Is(err, ErrRateLimit):
return "429 Too Many Requests"
default:
return "500 Internal Server Error"
}
}
func main() {
fmt.Println(handle(nil))
fmt.Println(handle(fmt.Errorf("get /users/7: %w", ErrNotFound)))
fmt.Println(handle(fmt.Errorf("checking token: %w", ErrDenied)))
fmt.Println(handle(errors.New("disk on fire")))
}
That's a whole HTTP error layer in fifteen lines, and adding a case is a two-line change.
Note the switch with no expression after it — Go's replacement for
else if chains, which you met in the control-flow module. Here it shines,
because each case is a function call rather than a comparison.
Combining errors with errors.Join
When several independent things fail, you don't have to pick one:
package main
import (
"errors"
"fmt"
)
var (
ErrNoName = errors.New("name is required")
ErrNoEmail = errors.New("email is required")
)
func validate(name, email string) error {
var errs []error
if name == "" {
errs = append(errs, ErrNoName)
}
if email == "" {
errs = append(errs, ErrNoEmail)
}
return errors.Join(errs...)
}
func main() {
err := validate("", "")
fmt.Println(err)
fmt.Println("missing name:", errors.Is(err, ErrNoName))
fmt.Println("missing email:", errors.Is(err, ErrNoEmail))
fmt.Println("valid input ->", validate("Ada", "ada@example.com"))
}
errors.Join returns nil when every argument is nil — so the happy path
needs no special case — and errors.Is finds any of the joined errors. It
prints them one per line.
When not to use a sentinel
A sentinel is a fixed value, so it can't carry data. If the caller needs to know which field was invalid or how long to wait before retrying, a sentinel isn't enough — you need a custom error type, which is two lessons away.
Also: a sentinel is part of your package's public API. Once callers compare
against ErrNotFound, you can't remove it. Export the ones callers genuinely
need to branch on, and keep the rest unexported.
Your turn
Declare ErrEmpty, return it wrapped from firstWord when the input has no
words, and use errors.Is in main to detect it:
hello
input was empty
package main
import (
"errors"
"fmt"
"strings"
)
// declare ErrEmpty here
func firstWord(s string) (string, error) {
fields := strings.Fields(s)
if len(fields) == 0 {
// return an error that wraps ErrEmpty
}
return fields[0], nil
}
func main() {
w, err := firstWord("hello there")
if err == nil {
fmt.Println(w)
}
_, err = firstWord(" ")
if errors.Is(err, ErrEmpty) {
fmt.Println("input was empty")
}
}
package main
import (
"errors"
"fmt"
"strings"
)
var ErrEmpty = errors.New("no words in input")
func firstWord(s string) (string, error) {
fields := strings.Fields(s)
if len(fields) == 0 {
return "", fmt.Errorf("firstWord: %w", ErrEmpty)
}
return fields[0], nil
}
func main() {
w, err := firstWord("hello there")
if err == nil {
fmt.Println(w)
}
_, err = firstWord(" ")
if errors.Is(err, ErrEmpty) {
fmt.Println("input was empty")
}
}
Next: how much context to add, and where.