Errors in Go: errors.Is, errors.As, Wrapping, and Sentinel Errors
Hey everyone!
In Go, errors are values. There are no exceptions: you return error and the caller decides what to do. This model is simple, but it requires knowing how to create, propagate, and check errors consistently. Otherwise they turn into lost strings or logs that don’t help in production.
In this post we’ll look at sentinel errors, wrapping with %w, errors.Is, errors.As, and patterns for APIs and libs that make code easier to debug and handle.
If you prefer video, check out this video on YouTube where I explain Go errors in practice.
1) Why errors are values in Go
Go has no try/catch. The rule is: function returns error, caller handles it. That means:
- Handling is explicit at each layer (or you propagate consciously).
- Errors can be compared and checked without tricks.
- You can add context to the error when propagating (wrapping) without losing the original error.
For this to work well, you need: sentinel errors for known conditions, wrapping for context, and errors.Is / errors.As for checking. Let’s go step by step.
2) Sentinel errors
Sentinel errors are errors defined as variables (usually at package level) and used to represent a specific condition that the caller may want to handle.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package store
import "errors"
var (
ErrNotFound = errors.New("store: resource not found")
ErrConflict = errors.New("store: conflict")
ErrValidation = errors.New("store: validation failed")
)
func (s *Store) GetByID(ctx context.Context, id string) (*Resource, error) {
r, err := s.db.Query(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return r, nil
}
Callers can branch on the sentinel:
1
2
3
4
5
6
7
resource, err := store.GetByID(ctx, id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, nil // or 404
}
return nil, err
}
When to use: for domain or contract conditions that the caller needs to distinguish (e.g. “not found”, “conflict”, “unauthorized”). Avoid creating a sentinel for every message. Use only when the flow or API response actually changes.
3) Wrapping with %w
Wrapping is adding context to an error when propagating it while keeping the original error in the chain. In Go you do this with fmt.Errorf and the %w verb (since Go 1.13).
1
2
3
4
5
6
7
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
The caller can still use errors.Is and errors.As on the returned error because the original err stays in the chain. Use %w once per level. Don’t wrap the same error again in the same package without adding new context.
Wrong: losing the original error (don’t use %v when you want inspection):
1
return nil, fmt.Errorf("get user: %v", err) // errors.Is(err, ErrNotFound) won't work
Right: preserve with %w:
1
return nil, fmt.Errorf("get user %s: %w", id, err) // chain intact
4) errors.Is
errors.Is(err, target) checks whether err is exactly target or whether, at some point in the wrap chain, the error matches target. It’s the recommended way to check sentinels.
1
2
3
4
5
6
7
8
9
10
resource, err := store.GetByID(ctx, id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, nil
}
if errors.Is(err, store.ErrConflict) {
return nil, ErrConflictResponse
}
return nil, err
}
It works with wrapped errors:
1
2
3
4
// somewhere: return nil, fmt.Errorf("loading resource: %w", store.ErrNotFound)
if errors.Is(err, store.ErrNotFound) {
// still true
}
Use errors.Is whenever you want to compare against a known value (sentinel). Don’t use err == store.ErrNotFound when there’s wrapping. It can fail.
5) errors.As
errors.As(err, &target) walks the error chain and, if it finds an error that matches the type of target, assigns it to target and returns true. Use it for errors that carry data (structs, codes, fields).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}
func ValidateUser(u *User) error {
if u.Email == "" {
return &ValidationError{Field: "email", Message: "required"}
}
return nil
}
The caller can extract the type and use the fields:
1
2
3
4
5
6
7
if err := ValidateUser(user); err != nil {
var verr *ValidationError
if errors.As(err, &verr) {
return fmt.Sprintf("field %s: %s", verr.Field, verr.Message)
}
return err.Error()
}
When to use: when the error needs to carry structured information (invalid field, business code). For simple conditions with no extra data, sentinel + errors.Is is enough.
6) Patterns for APIs and libraries
In libraries (low level)
- Return sentinels for conditions the caller should distinguish (e.g.
ErrNotFound). - Don’t wrap errors you return yourself. Let the caller add context if they want.
- For dependency errors (e.g.
sql.ErrNoRows), translate to your own sentinels when it makes sense (e.g.ErrNotFound) and return directly, without unnecessary wrapping.
1
2
3
4
5
// Lib: direct return
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
In application layers (services, handlers)
- Wrap when propagating, with useful context:
fmt.Errorf("get user %s: %w", id, err). - Define sentinels in the domain (e.g.
ErrUserNotFound,ErrDuplicateEmail) and useerrors.Isin handlers to decide HTTP status or response.
Custom types vs sentinels
| Situation | Recommendation |
|---|---|
| Known condition, no data | Sentinel + Is |
| Error with data (field, code) | Custom type + As |
| Just a message to the caller | fmt.Errorf with %w |
7) Full example: API with repository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
)
// Package domain or store
var ErrNotFound = errors.New("resource not found")
type User struct{ ID, Email string } // simplified for the example
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg }
// Repository: returns sentinels, no wrap
func (r *Repo) FindByID(ctx context.Context, id string) (*User, error) {
var u User
err := r.db.GetContext(ctx, &u, "SELECT * FROM users WHERE id = $1", id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &u, nil
}
// Service: wrap when propagating
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
// HTTP handler: Is and As for response
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := h.svc.GetUser(r.Context(), r.PathValue("id"))
if err != nil {
if errors.Is(err, ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
var valErr *ValidationError
if errors.As(err, &valErr) {
http.Error(w, valErr.Msg, http.StatusBadRequest)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
8) What to avoid
- Don’t use
err == sentinelwhen there’s wrapping. Prefererrors.Is(err, sentinel). - Don’t use
%vfor the inner error if you want the caller to useIs/As. Use%w. - Don’t create too many sentinels. Reserve them for conditions the caller actually handles differently.
- Don’t put volatile or sensitive data in error types that may be logged (avoid passwords, tokens).
Conclusion
A good error model in Go combines:
- Sentinel errors for conditions the caller needs to identify (
errors.Is). - Wrapping with
%win application layers to add context without losing the chain. - Custom types when the error needs to carry data (
errors.As). - Clear rules in APIs and libs: sentinels where it makes sense, wrap in the application, without overdoing it in the same layer.
That makes errors easier to log and handle in handlers and clients. To go deeper, the post Why context.Context is the nervous system of modern Go shows how context and cancellation fit into flows that return errors.
See you next time!
References
- Package errors – Official documentation for the
errorspackage (errors.Is, errors.As, wrapping). - Working with Errors in Go 1.13 – Go Blog post on error wrapping and
%w(Go 1.13). - Effective Go, Errors section – Error handling in the official guide.
- Errors are values – Go Blog post (Rob Pike) on errors as values.
- Don’t just check errors, handle them gracefully – Dave Cheney on sentinel errors, wrapping, and best practices.
- Go Code Review Comments, Error strings – Conventions for error messages and handling.
