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 own ErrNotFound).
  • 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 %w in fmt.Errorf to 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 err itself is equal to target (using ==, or a custom Is(error) bool method if err defines one).
  • If not equal, errors.Is checks whether err has an Unwrap() error method. 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) or Unwrap() returns nil / doesn't exist (returns false).
  • errors.As works 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.Errorf with %w produces a value of an unexported wrapping type whose Unwrap() returns the wrapped error — that's the only special behavior %w triggers; every other verb like %v or %s just 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.As will walk through all of it as long as every link implements Unwrap().

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.New sentinel 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 %v when the caller might reasonably want to inspect or match the original error.
  • Implement Unwrap() error on any custom error type that holds another error, so the whole chain stays traversable by errors.Is / errors.As.
  • Export sentinel errors (ErrNotFound, not errNotFound) so other packages can compare against them with errors.Is.
  • Prefer errors.Is over == and errors.As over 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 panic for 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 InsufficientFundsError struct with Balance and Requested fields and an Error() string method that reports both values. Write a withdraw(balance, amount float64) error function that returns it when amount > balance, and print the error from main.
  • Create a sentinel ErrPermissionDenied with errors.New. Write a function that wraps it with fmt.Errorf and extra context (e.g. "opening /etc/shadow: %w"), then use errors.Is in main to detect the permission failure and print a friendly message instead of the raw error.
  • Extend Example 3's QueryError pattern: add a second custom error type, ConnectionError, that wraps QueryError (so the chain is now three levels deep: ConnectionErrorQueryErrorErrTimeout). Confirm with errors.Is that ErrTimeout is still reachable from the outermost error.

Summary

  • The built-in error type is a one-method interface (Error() string); any type with that method satisfies it implicitly, with no implements declaration needed.
  • Sentinel errors (var ErrX = errors.New(...)) let callers check for a specific failure by identity using errors.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; implement Unwrap() error on custom types that hold another error.
  • A nil concrete pointer boxed into an error interface is not a nil interface — always return the bare nil literal for the no-error case.
  • Prefer errors.Is / errors.As over == or type assertions, since only the former correctly traverse wrapped error chains.