04 · Custom Errors & Error Wrapping¶
Beyond errors.New¶
Level 1, Module 8 established that errors
are ordinary values returned as the last result. That works fine until a
caller needs to react differently depending on what went wrong — retry a
timeout, prompt the user on a validation failure, give up on a permission
error. A plain string cannot be inspected reliably, and comparing error text
(if err.Error() == "not found") is fragile and breaks the moment someone
rewords the message.
Go gives you two better tools: sentinel errors for fixed conditions, and custom error types for errors carrying data. Wrapping ties them together across layers.
Sentinel errors¶
A sentinel is a package-level error variable that callers compare against. Declare it once, return it everywhere the condition occurs:
package main
import (
"errors"
"fmt"
)
// Sentinels are conventionally named Err... and declared at package level.
var (
ErrNotFound = errors.New("record not found")
ErrUnauthorized = errors.New("unauthorized")
)
var db = map[int]string{1: "Ada", 2: "Grace"}
func lookup(id int, admin bool) (string, error) {
if !admin {
return "", ErrUnauthorized
}
name, ok := db[id]
if !ok {
return "", ErrNotFound
}
return name, nil
}
func main() {
for _, tc := range []struct {
id int
admin bool
}{{1, true}, {99, true}, {1, false}} {
name, err := lookup(tc.id, tc.admin)
switch {
case errors.Is(err, ErrNotFound):
fmt.Printf("id %d: no such record\n", tc.id)
case errors.Is(err, ErrUnauthorized):
fmt.Printf("id %d: access denied\n", tc.id)
case err != nil:
fmt.Println("unexpected:", err)
default:
fmt.Printf("id %d: %s\n", tc.id, name)
}
}
// Output:
// id 1: Ada
// id 99: no such record
// id 1: access denied
}
Standard-library examples you already use: io.EOF, sql.ErrNoRows,
os.ErrNotExist.
Custom error types¶
When the error needs to carry data — which field failed, which HTTP status,
how long to wait before retrying — define a type implementing error:
package main
import (
"errors"
"fmt"
)
// ValidationError carries the offending field and value.
type ValidationError struct {
Field string
Value any
Rule string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %q (value %v): %s", e.Field, e.Value, e.Rule)
}
type User struct {
Name string
Email string
Age int
}
func validate(u User) error {
if u.Name == "" {
return &ValidationError{Field: "Name", Value: u.Name, Rule: "must not be empty"}
}
if u.Age < 0 || u.Age > 150 {
return &ValidationError{Field: "Age", Value: u.Age, Rule: "must be between 0 and 150"}
}
return nil
}
func main() {
err := validate(User{Name: "Ada", Age: 200})
var ve *ValidationError
if errors.As(err, &ve) {
// We now have the structured data, not just a string.
fmt.Println("field:", ve.Field) // field: Age
fmt.Println("rule:", ve.Rule) // rule: must be between 0 and 150
}
fmt.Println(err)
// validation failed on "Age" (value 200): must be between 0 and 150
}
Use a pointer receiver (*ValidationError) and return &ValidationError{}.
Two different *ValidationError values then compare as different errors, which
is what you want, and errors.As targets **ValidationError naturally.
Wrapping with %w¶
As an error travels up through layers, each layer wants to add context
("reading config", "loading user 42") without destroying the original. The
%w verb in fmt.Errorf wraps: the new error's message includes the old
one, and the original stays retrievable.
package main
import (
"errors"
"fmt"
"os"
)
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %s: %w", path, err)
}
return data, nil
}
func startup() error {
if _, err := readConfig("/nonexistent/app.yaml"); err != nil {
return fmt.Errorf("startup: %w", err)
}
return nil
}
func main() {
err := startup()
fmt.Println(err)
// startup: reading config /nonexistent/app.yaml: open /nonexistent/app.yaml: no such file or directory
// Despite two layers of wrapping, the root cause is still identifiable:
fmt.Println(errors.Is(err, os.ErrNotExist)) // true
}
%v would produce the same text but sever the chain — errors.Is would
return false. Choose deliberately:
%wwhen callers may reasonably want to inspect the cause.%vwhen the underlying error is an implementation detail you do not want to commit to as part of your API.
errors.Is vs errors.As¶
Both walk the wrap chain; they answer different questions.
errors.Is(err, ErrNotFound) // "is this specific error value anywhere in the chain?"
errors.As(err, &target) // "is there an error of this TYPE in the chain? give it to me"
Never use err == ErrNotFound on a possibly-wrapped error — it compares only
the outermost layer and silently returns false.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return "query " + e.Query + ": " + e.Err.Error() }
// Unwrap makes this type participate in the errors.Is/As chain.
func (e *QueryError) Unwrap() error { return e.Err }
func run() error {
inner := &QueryError{Query: "SELECT * FROM users", Err: ErrNotFound}
return fmt.Errorf("handler: %w", inner)
}
func main() {
err := run()
fmt.Println(err == ErrNotFound) // false -- naive comparison fails
fmt.Println(errors.Is(err, ErrNotFound)) // true -- walks the whole chain
var qe *QueryError
if errors.As(err, &qe) {
fmt.Println("failing query:", qe.Query) // failing query: SELECT * FROM users
}
}
The Unwrap() error method is what makes a custom type transparent to
errors.Is/errors.As. Without it the chain stops at your type.
Joining multiple errors¶
Since Go 1.20, errors.Join combines several errors into one; errors.Is
matches against any of them. Perfect for validating every field and reporting
all problems at once instead of stopping at the first.
package main
import (
"errors"
"fmt"
)
var (
ErrNameRequired = errors.New("name is required")
ErrEmailRequired = errors.New("email is required")
)
func validateAll(name, email string) error {
var errs []error
if name == "" {
errs = append(errs, ErrNameRequired)
}
if email == "" {
errs = append(errs, ErrEmailRequired)
}
return errors.Join(errs...) // returns nil if errs is empty
}
func main() {
err := validateAll("", "")
fmt.Println(err)
// name is required
// email is required
fmt.Println(errors.Is(err, ErrEmailRequired)) // true
}
fmt.Errorf also accepts multiple %w verbs, e.g.
fmt.Errorf("%w and %w", err1, err2).
Practical guidelines¶
- Add context at each layer, but do not repeat it.
"reading config: open x: no such file"is useful;"error: failed: error reading: error"is not. - Error strings are lowercase and have no trailing punctuation, because they get embedded into larger messages.
- Handle an error or return it — never both. Logging and returning produces duplicate noise at every level.
- Sentinels and exported error types are part of your public API. Changing them breaks callers.
How It Actually Works¶
fmt.Errorf("...: %w", err) doesn't concatenate strings and forget the original —
the %w verb makes fmt construct a *fmt.wrapError, a small struct holding the
formatted message plus a reference to the original error, and implementing an
Unwrap() error method that returns it. errors.Is and errors.As walk that chain
by repeatedly calling Unwrap() (or, since Go 1.20, also handling Unwrap() []error
for errors joined by errors.Join) until they find a match or run out of chain —
it's a plain linked-list traversal, not any kind of magic matching. errors.Is
compares with == (or a custom Is(error) bool method) at each link, while
errors.As uses reflect to check whether each link's concrete type is assignable
to the target pointer's type. This is why comparing wrapped errors with ==
directly fails silently (you're comparing the wrapper to the sentinel, not unwrapping
first) but errors.Is succeeds — the traversal it does is exactly the manual
for { ... err = errors.Unwrap(err) } loop you'd otherwise have to write yourself.
Cheat sheet¶
| Task | Syntax |
|---|---|
| Sentinel error | var ErrX = errors.New("x") |
| Formatted error | fmt.Errorf("bad id %d", id) |
| Wrap with context | fmt.Errorf("loading: %w", err) |
| Compare to a sentinel | errors.Is(err, ErrX) |
| Extract a typed error | var e *MyErr; errors.As(err, &e) |
| Make a type unwrappable | func (e *MyErr) Unwrap() error |
| Peel one layer | errors.Unwrap(err) |
| Combine errors | errors.Join(err1, err2) |
| Custom error type | func (e *MyErr) Error() string |
Related lessons¶
- Error basics and the
if err != nilidiom: Level 1, Module 8. erroris an interface — see Module 1, including the nil-interface trap that bites custom error types especially hard.- Testing error paths with
errors.Isin table-driven tests: Module 6.
Exercise¶
Write a tiny file-based key/value store with two sentinels (ErrKeyNotFound,
ErrStoreClosed) and one custom type ParseError{Line int, Err error} with an
Unwrap method. Get(key string) should return ErrKeyNotFound for a missing
key, and wrap any underlying os error with fmt.Errorf("get %q: %w", key,
err). In main, exercise all three paths and use errors.Is for the
sentinels and errors.As to print the failing line number from ParseError.
Verify that errors.Is(err, os.ErrNotExist) still works through your wrapping.