How to Implement the error Interface for Custom Errors

Implement the error interface in Go by adding an Error() method that returns a string to your custom type.

The problem with strings

You are building a service that validates user input. A request arrives with an email address that is already taken. Returning a generic "validation failed" string works for a quick script, but your frontend needs to show a specific message, and your logging system needs to categorize the failure. You could return "email already exists", but then the caller has to parse text to decide what to do. Text parsing is fragile. Localization breaks it. Typos break it.

You need more than a string. You need a type that carries context, codes, or metadata, while still fitting into Go's error handling pipeline. Go solves this with custom error types. You define a struct, add one method, and the type becomes an error. The caller gets the string for display and the struct for inspection.

The error interface is just one method

Go's error handling revolves around a single interface defined in the standard library. The error interface requires exactly one method: Error() string. Any type that defines a method with that signature satisfies the interface. You do not declare implements error. The compiler checks the method set. If your type has Error() string, it is an error.

This is structural typing. You get the behavior without the ceremony. You can define an error in one package and use it in another without importing the interface definition. The compiler verifies the shape at compile time. If the method is missing, the build fails. If the method exists, the type works everywhere an error is expected.

Define the method. The interface follows.

Minimal custom error

Here is the simplest custom error. It holds a field name and a code. The Error method formats them into a string.

package main

import "fmt"

// ValidationError holds details about a failed validation.
type ValidationError struct {
	Field string // name of the field that failed
	Code  string // machine-readable error code
}

// Error returns a human-readable description of the validation failure.
// This method signature satisfies the error interface implicitly.
func (e ValidationError) Error() string {
	// Combine code and field into a standard message format.
	return fmt.Sprintf("validation error: %s on field %q", e.Code, e.Field)
}

The receiver name e is convention. It matches the type name and keeps the signature short. The method name Error starts with a capital letter because interface methods must be exported. The method returns a string. That is all the compiler requires.

How the interface works under the hood

When you return a custom error, the compiler constructs an interface value. An interface value in Go is a pair: a concrete type and a value. When you return ValidationError{Field: "email", Code: "duplicate"}, the interface value stores the type ValidationError and a pointer to the struct data.

At runtime, when a caller invokes fmt.Println(err), the runtime dispatches to the Error method on the stored type. The method receives the struct data and returns the string. The caller sees the string. The type information remains available inside the interface box. If the caller knows the concrete type, they can extract it and inspect the fields.

This design keeps error handling fast. The interface dispatch is a single indirect call. The struct data lives on the heap or stack depending on escape analysis. The compiler optimizes the allocation. You get flexibility without paying a heavy runtime cost.

Realistic example: validation with context

Custom errors shine when you need to pass structured data through the error chain. Here is a function that validates input and returns a custom error. The caller can check the error and react based on the code.

package main

import (
	"fmt"
	"strings"
)

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

// Error returns a human-readable description of the validation failure.
func (e ValidationError) Error() string {
	return fmt.Sprintf("validation error: %s on field %q", e.Code, e.Field)
}

// RegisterUser validates the email and returns an error if invalid.
func RegisterUser(email string) error {
	if email == "" {
		// Return a structured error so the caller can inspect the field.
		return ValidationError{Field: "email", Code: "required"}
	}
	if !strings.Contains(email, "@") {
		return ValidationError{Field: "email", Code: "invalid_format"}
	}
	return nil
}

func main() {
	err := RegisterUser("bad-email")
	if err != nil {
		// The error prints via the Error method.
		fmt.Println(err)
	}
}

The Error method has a contract beyond the signature. It must be safe to call multiple times. It must not modify the error state. It must not panic. Callers may invoke Error() for logging, for display, or for comparison. If your method panics, the error handling chain breaks. If your method changes state, concurrent calls may produce inconsistent results. Treat Error() as a pure function that derives a string from the struct fields.

Custom errors carry data. Strings carry noise.

Pitfalls: receivers and nil pointers

Choose the receiver carefully. A value receiver means the error is copied when returned. This is safe and idiomatic for simple errors. A pointer receiver means the error interface holds a pointer. Use a pointer receiver if your error type is large or if you need to mutate state inside the error. Mutating errors is rare and usually a sign of a design problem. Errors should be immutable facts about a failure.

If you use a pointer receiver, you must return a pointer. The compiler rejects a value return with ValidationError does not implement error (Error method has pointer receiver). This error message tells you exactly what is wrong. The method is defined on *ValidationError, but you are returning ValidationError. The types do not match.

The bigger trap is the nil pointer. If you return a nil pointer wrapped in an interface, the interface value is not nil. The interface contains a type and a nil data pointer. The check if err != nil evaluates to true. If the caller calls Error(), the method may panic because it dereferences the nil pointer.

Never return a nil pointer wrapped in an interface. Return a non-nil pointer or a value. If you need to represent "no error", return nil directly, not a pointer to a struct.

func BadExample() error {
	var e *ValidationError
	// e is nil. Returning e creates a non-nil interface with a nil pointer.
	// This breaks if err != nil checks and may panic on Error().
	return e
}

func GoodExample() error {
	// Return nil directly. The interface value is nil.
	return nil
}

Nil pointers inside interfaces are not nil errors. Check the type, not just the value.

Convention asides

Go conventions reduce cognitive load. The receiver name is usually one or two letters matching the type. Use e for error receivers. Use err for the variable holding the error value. This distinction helps readers scan code. The receiver is the type definition. The variable is the runtime value.

Public names start with a capital letter. Private names start lowercase. The Error method must be public. The struct fields can be private if you want to enforce immutability. If the fields are private, provide constructor functions to create the error. This prevents callers from creating invalid error states.

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally ignore an error. The compiler forces you to handle it or discard it explicitly. Custom errors fit this pattern perfectly. You return the error, the caller checks it, and the program continues or aborts.

Trust gofmt. The tool enforces consistent formatting. You do not need to debate indentation or spacing. Focus on the logic. The tool handles the style.

When to build a custom error

Go provides several ways to create errors. Pick the tool that matches the data you need to pass.

Use errors.New when you need a simple, one-off error message with no extra data.

Use fmt.Errorf when you need to format a message with variables or wrap an existing error.

Use a custom struct with Error() string when you need to attach metadata, error codes, or allow callers to inspect specific fields.

Use fmt.Errorf with %w when you want to preserve the error chain for errors.Is and errors.As checks.

Use a sentinel error variable when you have a small, fixed set of known errors that callers check with errors.Is.

Pick the error shape that matches the data you need to pass.

Where to go next