34. Sentinel errors and `errors.Is`

📖 Reading · 10 min
💡 Every code box below is live — edit it and hit Run.

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.