Skip to content
GoBeginner6 min read

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.

GoBackend.dev
GoError HandlingErrors

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

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 idea

errors.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 with errors.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 with errors.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 using errors.Is/errors.As — brittle the moment wording changes anywhere in the chain.
  • Wrapping with fmt.Errorf but the %v verb instead of %w — this produces a similar-looking message but doesn't create a chain errors.Is/errors.As can 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, so errors.Is against any single sentinel only matches the one call site that happened to reuse it.
  • Using panic for expected, recoverable failures like a 404 or a validation error — that's what error is 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'