Go Custom Error Type Guide to the error Interface in Go
How the Go error interface works and how to build custom error types with fields, Unwrap and Is, plus the pointer receiver traps that bite with errors.As.

errors.New("something broke") is enough for most errors. It produces a message, and a caller can print it or compare it against a variable. It cannot tell the caller which field was wrong, how many seconds to wait or what status code to send, and as soon as the code that handles an error needs more than a string, you write your own type. That is not heavy: the error interface is one method, and a custom error is a struct with that method attached.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article builds up a custom error from nothing: the smallest version, then fields, wrapping, matching and the traps that come with pointer receivers. It sits inside the Go error handling guide, and the design question of whether to export the type at all is taken up in error design. Everything ran on Go 1.25.6.
The smallest custom error
The interface is defined in the universe block of every Go program:
type error interface {
Error() string
}Any type with that method set is an error, whether it is a struct, a string or a function. The shortest useful example is a named string type:
package main
import "fmt"
type NotFoundError string
func (e NotFoundError) Error() string { return string(e) + " not found" }
func lookup(name string) error {
return NotFoundError(name)
}
func main() {
err := lookup("config.toml")
fmt.Println(err)
fmt.Printf("%T\n", err)
}config.toml not found
main.NotFoundErrorThis compiles to an error with a distinct type, main.NotFoundError, which a caller can detect with a type switch or errors.As and which carries its payload in the value itself. It also works as a comparable value, since strings compare, so err == NotFoundError("config.toml") is true. That makes it a decent fit for a closed set of simple cases. For anything with more than one fact, you want a struct.
A struct with data
A validation failure is a good example of an error with structure: the caller wants to know which field and why, perhaps to highlight a form input.
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{Field: "age", Reason: "must be between 0 and 150"}
}
return nil
}
func main() {
err := validateAge(200)
fmt.Println(err)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("field:", ve.Field)
}
}invalid age: must be between 0 and 150
field: ageThree conventions are at work, and each has a reason.
The method has a pointer receiver and the function returns &ValidationError{...}. An error value gets copied around and compared, and returning a pointer means two returns of the same error are identical and the type is cheap to pass. It also matches the standard library: *fs.PathError, *json.SyntaxError and *net.OpError are all pointers. The most common bug with custom errors comes from mixing the two, as you will see below.
The type name ends in Error. That is the naming convention for types, and it gives *ValidationError and *QuotaError. Sentinel variables begin with Err, as in ErrNotFound. The distinction is useful when you read a package's index, since the capitalised prefix tells you whether something is a value to compare or a type to extract (sentinel errors).
The message is lower case with no trailing punctuation. Errors are often embedded in longer sentences, as in parse config: invalid age: must be between 0 and 150, and a capital or a full stop in the middle reads badly (message style).
Extracting the type with errors.As
errors.As(err, &target) walks the chain, and for each error asks whether it is assignable to the type of target. If so, it stores the value in target and returns true. Wrapping the error in the middle of the chain does not stop it:
package main
import (
"errors"
"fmt"
)
type StatusError struct {
Code int
Msg string
}
func (e *StatusError) Error() string { return fmt.Sprintf("status %d: %s", e.Code, e.Msg) }
func fetch() error {
return &StatusError{Code: 503, Msg: "upstream unavailable"}
}
func load() error {
if err := fetch(); err != nil {
return fmt.Errorf("load profile: %w", err)
}
return nil
}
func main() {
err := load()
var se *StatusError
if errors.As(err, &se) && se.Code >= 500 {
fmt.Println("retryable:", err)
}
}retryable: load profile: status 503: upstream unavailableThe condition reads naturally: if there is a StatusError in the chain and its code is a server error, treat the call as retryable. The caller never matched on a message, and fetch can change its text without breaking anyone. That is the main reason to use a type over a string.
The target must be a non-nil pointer to a type that implements error (or to any interface type). The variable se is of type *StatusError, so the target is **StatusError, and that is why the call takes &se. Getting this wrong has two distinct failure modes.
The pointer receiver trap
Because Error has a pointer receiver here, only *StatusError satisfies error. The value type StatusError does not. Asking errors.As to look for the value type is therefore not a harmless miss: it is a programming error that the function reports with a panic.
package main
import (
"errors"
"fmt"
)
type StatusError struct{ Code int }
func (e *StatusError) Error() string { return fmt.Sprint("status ", e.Code) }
func main() {
defer func() { fmt.Println("recovered:", recover()) }()
err := error(&StatusError{Code: 404})
var se StatusError
fmt.Println(errors.As(err, &se))
}recovered: errors: *target must be interface or implement errorerrors.As panics with errors: *target must be interface or implement error, because StatusError is not an error type. Go's vet has an analyzer for this exact mistake (errorsas), which is one more reason to run go vet in CI. The opposite choice, a value receiver with errors.As(err, &se) where se is a value, works, and has its own cost: the error is copied on every return and on every wrap. The standard library chose pointers, and following it avoids surprises when your type sits beside theirs.
Wrapping a cause: Unwrap
A custom error often wraps another one: a "could not load config" error that carries the underlying file error. Give it an Unwrap method and the chain stays connected, so errors.Is and errors.As find the inner errors:
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
type ConfigError struct {
Path string
Err error
}
func (e *ConfigError) Error() string { return "config " + e.Path + ": " + e.Err.Error() }
func (e *ConfigError) Unwrap() error { return e.Err }
func load(path string) error {
if _, err := os.ReadFile(path); err != nil {
return &ConfigError{Path: path, Err: err}
}
return nil
}
func main() {
err := load("/no/such/app.toml")
fmt.Println(err)
fmt.Println(errors.Is(err, fs.ErrNotExist))
var ce *ConfigError
fmt.Println(errors.As(err, &ce), ce.Path)
}config /no/such/app.toml: open /no/such/app.toml: no such file or directory
true
true /no/such/app.tomlThe same pattern is what fmt.Errorf("...: %w", err) does behind the scenes: it builds a small wrapper type with an Unwrap method. Writing your own is worth it when the wrapper needs fields of its own, here the path, that a caller will read. If it only adds a message, %w is enough (wrapping errors).
Mind the Error method: it must not call itself through the chain by accident. Printing e.Err with %v is fine. Writing fmt.Sprint(e) inside Error() recurses until the stack overflows. A related rule is to avoid unwrapping so far in Error() that the message repeats the inner text twice once someone wraps it again. Pick one place to print the cause.
Go 1.20 added a second form, Unwrap() []error, for a type that wraps several errors at once. errors.Join returns such a value, and errors.Is and errors.As search every branch (errors.Join).
Customizing equality with Is
By default, errors.Is(err, target) compares with ==. Two *StatusError values from separate allocations are different pointers, so errors.Is(err, &StatusError{Code: 404}) is false even though they describe the same thing. If you want matching by meaning, implement an Is method that errors.Is will call on each error in the chain:
package main
import (
"errors"
"fmt"
)
type StatusError struct{ Code int }
func (e *StatusError) Error() string { return fmt.Sprint("status ", e.Code) }
func (e *StatusError) Is(target error) bool {
t, ok := target.(*StatusError)
return ok && (t.Code == 0 || t.Code == e.Code)
}
var ErrAnyStatus = &StatusError{}
func main() {
err := fmt.Errorf("get: %w", &StatusError{Code: 404})
fmt.Println(errors.Is(err, &StatusError{Code: 404}))
fmt.Println(errors.Is(err, &StatusError{Code: 500}))
fmt.Println(errors.Is(err, ErrAnyStatus))
}true
false
trueA target with a zero code acts as a wildcard, so ErrAnyStatus matches any status error, which is a compact way to offer "is this a status error at all" next to "is this exactly 404". Use Is sparingly: it makes the equality of your errors depend on code that readers have to find. The default pointer comparison is right most of the time.
Behavior instead of type
There is a lighter alternative to exporting a concrete type: export a small interface that describes what a caller can do with the error. The standard library does this in the net package, where an error may implement Timeout() bool, and callers check for the behavior rather than a type:
package main
import (
"errors"
"fmt"
"time"
)
type temporary interface{ Temporary() bool }
type busyError struct{ retryAfter time.Duration }
func (e busyError) Error() string { return "server busy" }
func (e busyError) Temporary() bool { return true }
func call() error { return fmt.Errorf("call: %w", busyError{2 * time.Second}) }
func isTemporary(err error) bool {
var t temporary
return errors.As(err, &t) && t.Temporary()
}
func main() {
fmt.Println(isTemporary(call()))
fmt.Println(isTemporary(errors.New("bad request")))
}true
falseerrors.As accepts an interface type as its target, and finds the first error in the chain that implements it. The package that creates busyError never exports it, and the caller never imports it: they share only a one-method interface. This is the loosest coupling you can get while still letting callers make a decision, and error design argues that it is the best default for packages with several callers. Retryable and permanent errors builds the idea out.
The typed nil trap
One more mistake belongs here because custom error types cause it. If you declare a variable of the concrete type and return it as error, a nil pointer becomes a non-nil interface:
package main
import "fmt"
type MyError struct{}
func (*MyError) Error() string { return "my error" }
func bad(fail bool) error {
var e *MyError
if fail {
e = &MyError{}
}
return e
}
func good(fail bool) error {
if fail {
return &MyError{}
}
return nil
}
func main() {
fmt.Println(bad(false) == nil, good(false) == nil)
}false truebad(false) returns an interface holding a nil *MyError, which is not nil, so a caller's if err != nil fires on success. Return the literal nil and keep the concrete variable inside the branch. The mechanism is explained in the nil interface that is not nil.
When == panics
Because an error is an interface value, comparing two of them with == compares their dynamic types and values. If the type is not comparable, such as a struct with a slice field, the comparison panics at run time. errors.Is is built to avoid that:
package main
import (
"errors"
"fmt"
)
type BatchError struct{ Failed []string }
func (e BatchError) Error() string { return fmt.Sprint(len(e.Failed), " failed") }
func main() {
var a error = BatchError{Failed: []string{"x"}}
var b error = BatchError{Failed: []string{"x"}}
fmt.Println(errors.Is(a, b))
defer func() { fmt.Println("recovered:", recover()) }()
fmt.Println(a == b)
}false
recovered: runtime error: comparing uncomparable type main.BatchErrorerrors.Is checks whether the target's type is comparable first and returns false when it is not, so asking is always safe. A bare == on the same two values panics with comparing uncomparable type main.BatchError. The practical rules follow. Keep error types comparable if you can, which means pointer receivers (a pointer is always comparable) or structs of plain fields. If a type has a slice, map or function field, return it by pointer, and always compare through errors.Is or errors.As and never with ==. A pointer receiver also makes two separately created errors unequal even when their fields match, which is usually the behavior you want: identity, not content, decides Is unless you write an Is method.
Testing a custom error
An error type is part of what a function promises, so test it as such. Check the type with errors.As and the data with plain assertions, and avoid asserting on the full message, which is more likely to change than the structure:
package main
import "fmt"
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return fmt.Errorf("validate user: %w", &ValidationError{Field: "age", Reason: "out of range"})
}
return nil
}package main
import (
"errors"
"testing"
)
func TestValidateAge(t *testing.T) {
tests := []struct {
name string
age int
wantField string
}{
{"ok", 30, ""},
{"negative", -1, "age"},
{"too old", 200, "age"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateAge(tc.age)
if tc.wantField == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error %v is not a *ValidationError", err)
}
if ve.Field != tc.wantField {
t.Errorf("Field = %q, want %q", ve.Field, tc.wantField)
}
})
}
}=== RUN TestValidateAge
=== RUN TestValidateAge/ok
=== RUN TestValidateAge/negative
=== RUN TestValidateAge/too_old
--- PASS: TestValidateAge (0.00s)
--- PASS: TestValidateAge/ok (0.00s)
--- PASS: TestValidateAge/negative (0.00s)
--- PASS: TestValidateAge/too_old (0.00s)
PASS
ok example.test/lab 1.268sThe test passes whether or not the function wraps the error in an extra layer, which is what you want: wrapping is an implementation detail, and the assertion survives a refactor that adds context. It also fails if someone changes the return to a plain errors.New, because then no *ValidationError is in the chain. More patterns, including how to inject a failing dependency, are in testing error paths.
Learn from the standard library
The best examples of custom errors are already on your machine. Several standard packages return structured errors with exported fields, and each shows the design in use. Here are three, extracted from real failures:
package main
import (
"encoding/json"
"errors"
"fmt"
"strconv"
)
func main() {
_, err := strconv.Atoi("12x")
var ne *strconv.NumError
if errors.As(err, &ne) {
fmt.Println("NumError:", ne.Func, ne.Num, ne.Err)
}
var v struct{ Age int }
err = json.Unmarshal([]byte(`{"Age": "old"}`), &v)
var ute *json.UnmarshalTypeError
if errors.As(err, &ute) {
fmt.Println("UnmarshalTypeError:", ute.Field, ute.Value, ute.Type)
}
err = json.Unmarshal([]byte(`{"Age": `), &v)
var se *json.SyntaxError
fmt.Println(errors.As(err, &se), err)
}NumError: Atoi 12x invalid syntax
UnmarshalTypeError: Age string int
true unexpected end of JSON inputstrconv.NumError records the function, the input and the underlying reason (strconv.ErrSyntax or strconv.ErrRange, itself a sentinel you can test). json.UnmarshalTypeError names the field, the JSON value kind and the Go type it was supposed to fit, which is enough to build a precise message for an API client. The last case is a surprise worth keeping: truncated input is reported as unexpected end of JSON input, and it is still a *json.SyntaxError, so the errors.As is true and the type's Offset field says how far the decoder got. I assumed the opposite before running it, which is the argument for running things. The lesson for your own types is that the set of types a function can return is part of its contract, and that contract is worth writing down in the doc comment so nobody has to find out by experiment. JSON unmarshal errors reads them in depth.
Notice the shape they share. Each has exported fields for data, a pointer receiver, a name that ends in Error, and a documented set of functions that return it. Copying that shape gives your callers an experience they already know, and it keeps your package from inventing a new way to ask the same question.
Checklist before you export one
Writing the type takes ten lines. Deciding to export it is the real commitment, since callers will write errors.As against it and your field names become API. A few questions help.
- Does any caller need to branch on it? If the only action is to log or return, a wrapped
errors.Newis enough. - Is a sentinel or a behavior interface enough? A fixed condition with no data is a sentinel. A property such as "temporary" or "timeout" can be an interface.
- Is the data stable? Fields exposed now must be supported later. Keep the exported surface to what callers will use.
- Does it wrap? If it carries a cause, add
Unwrap. - Is the receiver a pointer, and does
Error()tolerate a nil receiver? A nil-safeError()avoids a panic while formatting a log line from a half-built value, though a nil-valued error should never reach it.
Types that pass these are worth having: *fs.PathError and *json.SyntaxError carry the data a caller needs and little else. For how to turn them into HTTP responses or user messages without leaking internals, see domain errors and mapping errors to status codes. The reference for everything above is the errors package documentation and the Go blog's Working with Errors in Go 1.13.


