How to Create Custom Error Types in Go

Create a custom error type in Go by defining a struct with an Error() method to return a descriptive string.

Story: When a string isn't enough

You're writing a config loader for a service. It reads a YAML file, parses the structure, and validates fields. When it fails, you need to tell the caller exactly what went wrong. If the file is missing, that's one thing. If the JSON is malformed, that's another. If a required field is empty, you need to know which field.

A plain string error like "config load failed" loses all that detail. You could cram everything into the string, but then callers have to parse text to recover data. That's fragile and error-prone. You need a custom error type that carries the context as structured data. Go makes this straightforward. Errors are values. You define a struct, add an Error method, and you're done.

Errors are values, not exceptions

Go doesn't have exceptions. Functions return errors as regular values alongside their results. The error interface is the contract. It has one method: Error() string. Any type with that method is an error. This is implicit. You don't declare implements error. The compiler checks for the method.

Creating a custom error means defining a struct that holds extra data and implements that method. Think of a standard error as a sticky note that says "Something went wrong." A custom error is a structured report card. It still has a summary line for humans, but it also carries grades, comments, and specific codes that your code can inspect later.

The Error method has a specific job. It produces a string for logs, UI, or debugging. It is not a data format. Never parse the output of Error to recover data. If you need the data, access the struct fields directly. Parsing error strings breaks when the message changes. It's fragile and unnecessary in Go.

Minimal custom error

Here's the simplest custom error: a struct with data fields and the required method.

package main

import "fmt"

// ValidationError holds details about a failed validation check.
type ValidationError struct {
	Field  string
	Reason string
}

// Error returns a formatted message for display.
func (e *ValidationError) Error() string {
	return fmt.Sprintf("validation error: field %q: %s", e.Field, e.Reason)
}

func main() {
	err := &ValidationError{Field: "email", Reason: "invalid format"}
	fmt.Println(err)
}

The struct fields store data for programmatic access. The pointer receiver is convention for error types. The Error method satisfies the error interface. The pointer value allows the struct to be returned and wrapped.

When you define ValidationError, the compiler checks if it satisfies the error interface. Since it has an Error() string method, it does. You can pass &ValidationError{} anywhere an error is expected. When you print it, the fmt package calls the Error method and uses the result. The extra fields like Field are available to your code, but invisible to generic error handlers that only care about the string.

Errors are values. Inspect the data, not the string.

Realistic usage: wrapping and extraction

Real code wraps errors and checks types. You define a custom error deep in your stack, wrap it with context as it bubbles up, and extract the type at the top to handle it.

Here's a config loader that returns a custom error and wraps a sentinel error.

package main

import (
	"errors"
	"fmt"
)

// ConfigError represents a configuration failure.
type ConfigError struct {
	File string
	Line int
	Msg  string
}

// Error returns a descriptive message for the config error.
func (e *ConfigError) Error() string {
	return fmt.Sprintf("config error at %s:%d: %s", e.File, e.Line, e.Msg)
}

// ErrMissingKey is a sentinel error for missing configuration keys.
var ErrMissingKey = errors.New("missing required key")

The ConfigError struct stores location and message. The Error method formats the output for logs. The sentinel error allows simple equality checks.

Now here's the function that returns the error and the caller that extracts it.

func loadConfig(path string) error {
	if path == "" {
		return &ConfigError{File: "config.yaml", Line: 0, Msg: "path cannot be empty"}
	}
	// Simulate a deeper error wrapped with context.
	return fmt.Errorf("loading %s: %w", path, ErrMissingKey)
}

func main() {
	err := loadConfig("")
	if err != nil {
		fmt.Println(err)

		// Extract the custom type to inspect fields.
		var configErr *ConfigError
		if errors.As(err, &configErr) {
			fmt.Printf("File: %s, Line: %d\n", configErr.File, configErr.Line)
		}
	}
}

The function returns a custom error with details. The %w verb wraps the sentinel error, preserving the chain. errors.As traverses the chain to find the type. The pointer to pointer lets errors.As assign the found value.

When you wrap an error with fmt.Errorf and %w, the wrapper holds a reference to the original error. errors.As and errors.Is know how to follow these references. They walk the chain until they find a match. This means you can return a custom error deep in your stack, wrap it multiple times as it bubbles up, and still extract the original type at the top. The context accumulates, but the data remains accessible.

errors.As takes a pointer to a pointer. The first pointer is the error chain. The second pointer is the target variable. errors.As modifies the target variable to point to the found error. If you pass a value instead of a pointer, the compiler rejects it with second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type. You must use &configErr.

Sentinel errors are variables of type error. You define them once, usually at package level. Callers compare against them using errors.Is. Sentinels are great for well-known conditions like io.EOF or os.ErrNotExist. They are lightweight and easy to check. Custom structs are better when you have multiple errors of the same kind but different data. For example, multiple validation errors are all ValidationError, but each has a different field. A sentinel can't carry that variation.

Go's interface system is implicit. You don't announce that a type implements an interface. The compiler checks the method set. This keeps code decoupled. You can add error support to a type without modifying its definition, as long as you can add the method. For custom errors, you define the struct and the method together. This creates a cohesive unit.

Wrap errors to preserve context. Extract types to handle details.

Pitfalls and compiler errors

If you define a struct but forget the Error method, the compiler rejects it with cannot use myErr (variable of type MyError) as error value in return: MyError does not implement error (missing Error method). You must implement the interface explicitly. The compiler is strict about this.

If you use errors.As with a value target instead of a pointer, the compiler complains with second argument to errors.As must be a non-nil pointer. This is a common mistake. Always pass a pointer to the variable you want to fill.

Another pitfall is mixing value and pointer receivers. If you define Error with a value receiver, errors.As can still find the type, but the extracted value might be a copy. Convention dictates pointer receivers for error types. This matches the pattern of returning &MyError{} and allows consistency with wrapping mechanisms. The receiver name should be one or two letters matching the type, like (e *ConfigError). Using (this *ConfigError) or (self *ConfigError) breaks community norms.

Don't pass a *string in your error struct if you don't need to. Strings are cheap to pass by value. Use string fields directly.

The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Custom errors fit naturally into this pattern. You return the error, the caller checks it, and handles it.

Trust errors.As. It handles the chain for you.

When to use custom errors

Use a custom error struct when you need to attach data that callers must inspect, like a field name, an HTTP status code, or a retry hint.

Use a sentinel error variable when you have a small set of distinct errors and callers only need to check equality with errors.Is.

Use fmt.Errorf with %w when you want to wrap an existing error and add context without defining a new type.

Use a plain string error from errors.New when the error is terminal and no caller needs to branch on specific details.

Use a custom error struct when the error represents a category of failures with varying details, like validation errors or database constraint violations.

Use a sentinel error when the error is a unique condition, like "not found" or "already exists", and the distinction is binary.

Use fmt.Errorf when you are adding a layer of context, such as "failed to parse config: %w", and the underlying error already carries the necessary data.

Use a plain string error when the error is informative only, such as a log message that doesn't require programmatic handling.

Errors are values. Return them, check them, wrap them.

Where to go next