Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
Why Go treats errors as ordinary values instead of exceptions, how to build custom error types, how wrapping with %w preserves the original cause, and how errors.Is and errors.As actually work — with diagrams.
TL;DR
An error in Go is just a value satisfying a one-method interface — returned, checked, and optionally wrapped with fmt.Errorf("...: %w", err) so errors.Is and errors.As can still find the original cause underneath layers of context.
What You'll Learn
- Why Go represents failure as a returned value instead of a thrown exception
- How to define and return a custom error type carrying structured data
- How to wrap an error with %w to add context without losing the original
- How errors.Is and errors.As walk the wrapped-error chain
- The difference between a sentinel error and a custom error type, and when to use each
- When panic is actually appropriate instead of returning an error
Prerequisites
The problem
user, err := findUser(id)
if err != nil {
return err
}This is nearly every other line in idiomatic Go, and it looks tedious coming
from a language with exceptions — until the alternative shows its cost: an
exception can be thrown from any line, in any function, unwinding an
unknown number of stack frames before something catches it. An error
return value is visible, right there in the function signature, and the
compiler forces you to acknowledge it exists. This article covers what an
error actually is, and the tools for adding context and inspecting it
without losing that visibility.
error is just an interface
type error interface {
Error() string
}Any type with an Error() string method satisfies it. The standard library
gives you the common case for free:
err := errors.New("connection refused")
err2 := fmt.Errorf("could not connect to %s", host) // formatted, same ideaerrors.New and fmt.Errorf both return a value of an unexported type
whose Error() method returns the string you gave it — nothing more exotic
happening underneath.
Custom error types carry data
A plain string is fine for a one-off failure, but when calling code needs to react differently based on what went wrong (not just log a message), a custom type carrying structured data is the better tool:
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
func validate(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Msg: "must not be negative"}
}
return nil
}A caller that only wants to log the message can just do that — err.Error()
still works, since *ValidationError satisfies the plain error interface.
A caller that needs the structured Field back can extract it with
errors.As, covered next.
Wrapping: adding context without losing the original
Passing an error up through several layers of a call stack usually means
adding context at each layer — but returning a brand-new error at each step
throws away exactly the information a caller might need to react correctly.
fmt.Errorf with the %w verb solves this: it creates a new error that
wraps the original, keeping it reachable underneath the added message.
func fetchUser(id string) (*User, error) {
row, err := db.QueryRow(id)
if err != nil {
return nil, fmt.Errorf("fetchUser %s: %w", id, err) // wraps err
}
// ...
}Rendering diagram…
Every layer that wraps adds one more link in that chain. err.Error() on
the outermost error prints the whole chain of messages concatenated — but
the original error object is still there underneath, reachable by the
functions in the next section.
errors.Is: "is this error (or one it wraps) X?"
errors.Is checks whether a specific error — usually a sentinel error,
a package-level var — appears anywhere in the wrapped chain, not just at
the top:
var ErrNotFound = errors.New("not found")
func fetchUser(id string) (*User, error) {
if !exists(id) {
return nil, fmt.Errorf("fetchUser %s: %w", id, ErrNotFound)
}
// ...
}
user, err := fetchUser("42")
if errors.Is(err, ErrNotFound) {
// true — even though err's message is "fetchUser 42: not found",
// not the plain "not found" ErrNotFound itself
http.Error(w, "user not found", http.StatusNotFound)
return
}Without wrapping-aware errors.Is, this check would need an exact string
match against err.Error() — brittle the moment any layer changes its
message text. errors.Is walks the chain created by %w, comparing each
link against ErrNotFound, regardless of how much context was added on top.
errors.As: "give me the first error of this type"
errors.As does the equivalent extraction for a type instead of a
specific value — walking the same wrapped chain, but pulling out the first
error that matches a given type, so you can read its fields:
var valErr *ValidationError
if errors.As(err, &valErr) {
// err (or something it wraps) is a *ValidationError — valErr is now that value
fmt.Println("invalid field:", valErr.Field)
}errors.As takes a pointer to the variable it should populate, the same way
json.Unmarshal does — it needs somewhere to write the match it finds.
Sentinel error vs. custom type: which one
- Sentinel error (
var ErrNotFound = errors.New(...)) — use it when callers only need to know which specific failure occurred, checked witherrors.Is. Cheap, simple, no extra data. - Custom error type (
type ValidationError struct {...}) — use it when callers need data about the failure — which field, what limit was exceeded — extracted witherrors.As.
Many real functions return one or the other depending on the failure, and
callers use errors.Is/errors.As to branch on whichever applies, exactly
like a type switch over the possible failure modes of that function.
When to panic instead
panic exists for a different category of problem: programmer bugs and
states the program has no reasonable way to continue past — an index out
of range, a nil map write, a broken invariant your own code guarantees.
Defer, Panic, and Recover in Go covers the
mechanism in depth; the rule that matters here is simpler: an expected
failure — a missing record, invalid user input, a network timeout — is an
error, returned and handled. panic is reserved for the failures that
mean the program's own assumptions have already been violated.
Common mistakes
- Comparing
err.Error()strings instead of usingerrors.Is/errors.As— brittle the moment wording changes anywhere in the chain. - Wrapping with
fmt.Errorfbut the%vverb instead of%w— this produces a similar-looking message but doesn't create a chainerrors.Is/errors.Ascan walk; the original error is lost. - Returning a bare
errors.New("not found")from every call site — every one of those is a different error value, soerrors.Isagainst any single sentinel only matches the one call site that happened to reuse it. - Using
panicfor expected, recoverable failures like a 404 or a validation error — that's whaterroris for.
Summary
An error is any value with an Error() string method — nothing more
special than that. fmt.Errorf("...: %w", err) wraps an error with added
context while keeping the original reachable; errors.Is checks whether a
specific sentinel appears anywhere in that chain, and errors.As extracts
the first error of a matching type so you can read its data. Reach for a
sentinel when callers only need identity, a custom type when they need
structured data, and reserve panic for the failures that mean your
program's own assumptions broke — not for the ordinary, expected ones an
error return already handles cleanly.
Key Takeaways
- error is an interface with one method, Error() string — any type implementing it is a valid error
- fmt.Errorf("doing X: %w", err) wraps err, adding context while keeping the original error reachable
- errors.Is checks whether a specific sentinel error appears anywhere in the wrapped chain; errors.As extracts the first error of a matching type from that chain
- A sentinel error (var ErrNotFound = errors.New(...)) is for identity checks; a custom error type is for carrying structured data alongside the failure
- panic is for programmer bugs and unrecoverable states, not for expected failure conditions like 'not found' or 'invalid input'
Go Fundamentals Series
Article 5 of 13
- 1.Go Modules and Packages: Setting Up and Structuring a Go Project
- 2.Slices, Arrays, and Maps in Go: How They Actually Work
- 3.Pointers in Go: When to Use *T and Why
- 4.Structs, Methods, and Composition in Go
- 5.Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
- 6.Defer, Panic, and Recover in Go
- 7.Go Interfaces: Design Small, Testable Components
- 8.Goroutines and Channels in Go: A Fresher-Friendly Guide
- 9.Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
- 10.sync.WaitGroup, sync.Mutex, and the Race Detector in Go
- 11.Context in Go: Cancellation, Deadlines and Request Propagation
- 12.Building Background Workers in Go with Goroutines and Channels
- 13.Graceful Shutdown in Go HTTP Servers
funcRelated()[]Article
Structs, Methods, and Composition in Go
How to define your own types with structs, attach behavior with methods, and build up functionality through embedding instead of inheritance — with diagrams and real examples.
Defer, Panic, and Recover in Go
How defer actually schedules a call, what panic does to the call stack, why recover only works inside a deferred function, and the real-world pattern of recovering from panics in HTTP middleware — with diagrams.
Go Modules and Packages: Setting Up and Structuring a Go Project
What a Go module actually is, how go.mod and go.sum work, how packages and import paths map to directories, and how to structure a project before you write your first real program.