Creating Custom Errors
Go does not have exceptions. Instead, functions that can fail return an ordinary value that satisfies the built-in error interface, and callers check it explicitly with if err != nil. A plain errors.New("something broke") is often enough, but real programs need more: a way to attach structured data to a failure (which field was invalid, which HTTP status came back), a way to group related failures so callers can check “was this a not-found error?” without string-matching, and a way to preserve the original cause while adding context as an error travels up the call stack. That is what custom errors give you.
This lesson covers how to define your own error types, how sentinel errors and wrapping work, and how the standard library’s errors.Is and errors.As let calling code inspect an error chain safely and idiomatically.
Overview: How Errors Work in Go
The error type is not a class hierarchy or a special language construct — it is a one-method interface defined in the universe block (the implicit scope every Go file starts with):
type error interface {
Error() string
}
Any type that has an Error() string method automatically satisfies error — there is no implements keyword to write, no explicit declaration of intent. This is Go’s implicit interface satisfaction at work: the compiler checks structurally, at compile time, whether a type’s method set matches what’s required. That means you can make any type an error, including your own structs, and it will work anywhere an error is expected, including as the return value of a function or the argument to fmt.Println.
Under the hood, an error variable is an interface value: a two-word pair of (type, value). When you write return &MyError{...} from a function whose return type is error, Go boxes the pointer up into that interface value. This detail matters more than it looks — it is the root cause of one of the most common custom-error bugs in Go, covered later in Common Mistakes.
Go’s standard library gives you three complementary building blocks for creating and composing custom errors:
- Sentinel errors — package-level values created with
errors.New, compared by identity (e.g.io.EOF, or your ownErrNotFound). - Custom error types — structs that implement
Error() string, letting you attach fields like a status code, a field name, or a wrapped cause. - Wrapping — using
%winfmt.Errorfto attach context to an error while preserving the original for inspection further up the call stack.
The last of these relies on an optional method, Unwrap() error, that lets errors.Is and errors.As walk backwards through a chain of wrapped errors to find a match, even through several layers of custom types.
Syntax
There is no special syntax for “declaring a custom error” — you just write a type with an Error() string method, plus optionally Unwrap() error. The common shapes look like this:
// A sentinel error: a package-level value, compared by identity.
var ErrNotFound = errors.New("item not found")
// A custom error type with fields, satisfying the error interface.
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Msg)
}
// Wrapping an existing error with extra context.
wrapped := fmt.Errorf("loading config: %w", originalErr)
| Piece | Purpose |
|---|---|
errors.New(msg) |
Creates a simple error value carrying just a message; good for sentinel errors. |
Error() string |
The one method every error type must implement; called automatically by fmt.Println, %v, and %s. |
fmt.Errorf("...: %w", err) |
Formats a new error string while keeping err reachable through Unwrap(). |
Unwrap() error |
Optional method on a custom type that returns the error it wraps, enabling chain traversal. |
errors.Is(err, target) |
Reports whether target appears anywhere in err‘s chain. |
errors.As(err, &target) |
Finds the first error in the chain matching target‘s type and assigns it. |
Examples
Example 1: A Simple Custom Error Type
The most basic custom error is a struct with fields plus an Error() string method. Here, a validation failure carries which field was wrong and why, instead of just a flat string.
package main
import (
"fmt"
)
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Msg)
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Msg: "must not be negative"}
}
return nil
}
func main() {
err := validateAge(-5)
if err != nil {
fmt.Println(err)
}
}
Output:
validation failed on field "age": must not be negative
validateAge returns a *ValidationError as an error. Because the pointer type has an Error() string method, it satisfies the interface with no extra declaration. When fmt.Println receives an error value, it detects the Error() string method and calls it automatically, rather than printing the struct's raw fields.
Example 2: Sentinel Errors and errors.Is
A sentinel error is just a package-level error value that callers can compare against. The idiomatic way to compare is errors.Is, not ==, because it also works through wrapped errors (see Example 3).
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("item not found")
func findItem(id int) (string, error) {
items := map[int]string{1: "apple", 2: "banana"}
item, ok := items[id]
if !ok {
return "", ErrNotFound
}
return item, nil
}
func main() {
_, err := findItem(5)
if errors.Is(err, ErrNotFound) {
fmt.Println("lookup failed:", err)
}
}
Output:
lookup failed: item not found
ErrNotFound is declared once at package level and returned directly, unmodified, whenever a lookup fails. Callers anywhere in the program can check for this exact failure with errors.Is(err, ErrNotFound) without needing to know or parse the error's text.
Example 3: Wrapping Errors with %w and errors.As
Real programs often need to add context as an error travels up through several layers of function calls, while still letting the top-level caller find out what the original cause was. This example defines a custom QueryError type that wraps another error, implements Unwrap() so the chain stays traversable, and shows both errors.Is (checking for a specific sentinel) and errors.As (extracting a specific custom type) working through that chain.
package main
import (
"errors"
"fmt"
)
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string {
return fmt.Sprintf("query %q failed: %v", e.Query, e.Err)
}
func (e *QueryError) Unwrap() error {
return e.Err
}
var ErrTimeout = errors.New("connection timeout")
func runQuery(query string) error {
return &QueryError{Query: query, Err: ErrTimeout}
}
func main() {
err := runQuery("SELECT * FROM users")
fmt.Println(err)
if errors.Is(err, ErrTimeout) {
fmt.Println("the underlying cause was a timeout")
}
var qErr *QueryError
if errors.As(err, &qErr) {
fmt.Println("failed query was:", qErr.Query)
}
}
Output:
query "SELECT * FROM users" failed: connection timeout
the underlying cause was a timeout
failed query was: SELECT * FROM users
runQuery returns a *QueryError whose Err field holds ErrTimeout. Because *QueryError implements Unwrap() error, errors.Is can look past the QueryError wrapper to find ErrTimeout underneath, and errors.As can pull the *QueryError itself back out to read its Query field — even though main only ever holds a plain error interface value.
How It Works Step by Step
Walking through what happens when code calls errors.Is(err, target) on a wrapped error chain:
- Go first checks whether
erritself is equal totarget(using==, or a customIs(error) boolmethod iferrdefines one). - If not equal,
errors.Ischecks whethererrhas anUnwrap() errormethod. If so, it calls it to get the next error in the chain. - This repeats — check equality, then unwrap — until either a match is found (returns
true) orUnwrap()returnsnil/ doesn't exist (returnsfalse). errors.Asworks the same way, except at each step it checks whether the current error's type matches the target pointer's pointed-to type, and if so assigns it and stops.fmt.Errorfwith%wproduces a value of an unexported wrapping type whoseUnwrap()returns the wrapped error — that's the only special behavior%wtriggers; every other verb like%vor%sjust formats the string and adds no wrapping.- A chain can be arbitrarily long: a database driver error wrapped by a repository error wrapped by a service error wrapped by an HTTP handler error —
errors.Is/errors.Aswill walk through all of it as long as every link implementsUnwrap().
Common Mistakes
Mistake 1: Using %v Instead of %w Breaks the Error Chain
Formatting a wrapped error with %v instead of %w produces the same printed text, but it discards the underlying error entirely — there is nothing left for errors.Is to find.
err := fmt.Errorf("processing failed: %v", ErrTimeout)
if errors.Is(err, ErrTimeout) {
fmt.Println("this will never print")
}
// errors.Is returns false: %v just formats ErrTimeout into a string,
// so the resulting error has no Unwrap() method and no chain to walk.
The fix is to use %w whenever the wrapped value should remain inspectable by callers:
package main
import (
"errors"
"fmt"
)
var ErrTimeout = errors.New("connection timeout")
func main() {
err := fmt.Errorf("processing failed: %w", ErrTimeout)
if errors.Is(err, ErrTimeout) {
fmt.Println("timeout detected:", err)
}
}
Output:
timeout detected: processing failed: connection timeout
Mistake 2: Returning a Nil Pointer as a Non-Nil Error Interface
This is the single most notorious custom-error gotcha in Go. If a function returns a concrete pointer type (like *MyError) that gets implicitly converted to the error interface, a nil pointer value does not produce a nil interface — the interface's type slot is still set to *MyError, only its value slot is nil. The interface as a whole compares as non-nil.
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
func doSomething() *MyError {
return nil // no error occurred
}
func process() error {
var err *MyError = doSomething()
return err // BUG: the returned interface value is non-nil,
// even though the underlying pointer is nil!
}
func main() {
err := process()
if err != nil {
fmt.Println("got an error, but shouldn't have:", err)
}
}
The fix is to never let a typed nil pointer flow directly into an error-typed return — check it explicitly and return the untyped nil literal instead:
package main
import "fmt"
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
func doSomething() *MyError {
return nil
}
func process() error {
if err := doSomething(); err != nil {
return err
}
return nil
}
func main() {
err := process()
if err != nil {
fmt.Println("got an error:", err)
} else {
fmt.Println("no error occurred")
}
}
Output:
no error occurred
The general rule: a function whose signature returns the error interface should only ever return nil directly for the no-error case, never a nil value of some concrete pointer type.
Best Practices
- Use a plain
errors.Newsentinel when callers only need to check which failure occurred; use a custom struct type when callers need structured data (a field name, a status code, a retry count). - Always wrap with
fmt.Errorf("context: %w", err)rather than%vwhen the caller might reasonably want to inspect or match the original error. - Implement
Unwrap() erroron any custom error type that holds another error, so the whole chain stays traversable byerrors.Is/errors.As. - Export sentinel errors (
ErrNotFound, noterrNotFound) so other packages can compare against them witherrors.Is. - Prefer
errors.Isover==anderrors.Asover type assertions (err.(*MyError)) — both correctly traverse wrapped chains, while==and raw assertions only see the outermost layer. - Keep error messages lowercase and without trailing punctuation, following the standard library's convention, since they are frequently wrapped into larger sentences by callers.
- Don't use
panicfor ordinary, expected failure conditions (a missing file, a bad user request) — reserve it for programmer errors and truly unrecoverable situations. - Never discard an error with
_unless you have a specific, documented reason; an ignored error is a silent bug waiting to happen.
Practice Exercises
- Define a custom
InsufficientFundsErrorstruct withBalanceandRequestedfields and anError() stringmethod that reports both values. Write awithdraw(balance, amount float64) errorfunction that returns it whenamount > balance, and print the error frommain. - Create a sentinel
ErrPermissionDeniedwitherrors.New. Write a function that wraps it withfmt.Errorfand extra context (e.g."opening /etc/shadow: %w"), then useerrors.Isinmainto detect the permission failure and print a friendly message instead of the raw error. - Extend Example 3's
QueryErrorpattern: add a second custom error type,ConnectionError, that wrapsQueryError(so the chain is now three levels deep:ConnectionError→QueryError→ErrTimeout). Confirm witherrors.IsthatErrTimeoutis still reachable from the outermost error.
Summary
- The built-in
errortype is a one-method interface (Error() string); any type with that method satisfies it implicitly, with noimplementsdeclaration needed. - Sentinel errors (
var ErrX = errors.New(...)) let callers check for a specific failure by identity usingerrors.Is. - Custom struct error types let you attach structured data to a failure, accessible via
errors.As. - Wrap errors with
fmt.Errorf("context: %w", err), not%v, to preserve the chain; implementUnwrap() erroron custom types that hold another error. - A nil concrete pointer boxed into an
errorinterface is not a nil interface — always return the barenilliteral for the no-error case. - Prefer
errors.Is/errors.Asover==or type assertions, since only the former correctly traverse wrapped error chains.
