When a boolean isn't enough
You write a function that parses a configuration file. The file might be missing. The permissions might be wrong. The JSON might be malformed. Returning a simple bool tells the caller something broke, but not what. Returning a string works for humans, but the caller can't programmatically distinguish a "file not found" from a "syntax error" without fragile string matching.
Go solves this with the error interface. It turns errors into first-class values. You can create them, pass them around, inspect their structure, and chain them together. The interface is tiny, but the pattern it enables shapes how every Go program handles failure.
The error interface is a shape, not a class
The error interface lives in the standard library and looks like this:
type error interface {
Error() string
}
That's the entire definition. One method. No inheritance. No registration. Go uses structural typing. Any type that defines a method named Error returning a string automatically satisfies the error interface. The compiler checks the shape. If the shape matches, the type fits.
Think of it like a USB-C port. The port doesn't care if the cable is from Apple, Samsung, or a generic brand. It only cares that the connector has the right pins in the right places. If the pins match, it works. Your custom error type is the cable. The error interface is the port.
This design keeps the standard library small and lets you define errors exactly how you need them. You can add fields for status codes, retry flags, or request IDs. As long as you provide Error() string, the rest is up to you.
Minimal example
Here's the simplest custom error. A struct holds the data, and the method formats it for display.
// ErrNotFound represents a missing resource.
type ErrNotFound struct {
// ID holds the identifier of the missing item.
ID string
}
// Error returns the human-readable description.
func (e ErrNotFound) Error() string {
return "item not found: " + e.ID
}
The receiver name is e, a single letter matching the type. Go convention favors short receiver names like e for errors, b for buffers, or s for strings. You'll never see (this ErrNotFound) or (self ErrNotFound) in idiomatic code. The receiver name is just a handle for the method body; it doesn't appear in the method signature.
When you return ErrNotFound{ID: "42"} from a function that returns error, the compiler sees the return type is error. It checks ErrNotFound. Does it have Error() string? Yes. The value gets boxed into an interface value and flows to the caller.
Walk through what happens
Under the hood, an interface value in Go is a pair of pointers: one to a type descriptor and one to the data. When you assign a concrete error to an error variable, the runtime stores the type information and a pointer to the struct.
var err error = ErrNotFound{ID: "42"}
The variable err now holds the type ErrNotFound and the data {"42"}. When you call err.Error(), the interface dispatches to the concrete method. The runtime looks up the method table for ErrNotFound, finds Error, and calls it with the data pointer. This dispatch has a tiny cost, but it's negligible compared to I/O or network calls.
If you forget to define the method, the compiler catches it immediately. You'll see an error like cannot use e (variable of struct type ErrNotFound) as error value in return: ErrNotFound does not implement error (missing Error method). The message is explicit. It tells you exactly which method is missing.
Realistic example
In production code, errors often carry context. An HTTP handler might need to return a typed error so the middleware can set the right status code. Here's how that looks.
// ErrInvalidInput represents a validation failure.
type ErrInvalidInput struct {
// Field names the input that failed validation.
Field string
// Reason explains why the value is invalid.
Reason string
}
// Error returns a structured message for logging.
func (e ErrInvalidInput) Error() string {
return "invalid input: field " + e.Field + " is " + e.Reason
}
// CreateUser validates input and returns a typed error on failure.
func CreateUser(name string) error {
// Check for empty name before proceeding.
if name == "" {
return ErrInvalidInput{Field: "name", Reason: "empty"}
}
// Simulate success.
return nil
}
The caller can now inspect the error type. If the error is ErrInvalidInput, the caller knows exactly which field failed and can display a targeted message to the user. This is far better than parsing a generic string.
Go convention accepts the if err != nil { return err } pattern. It looks verbose compared to exceptions in other languages, but the verbosity makes the unhappy path visible. You can't miss an error check when it's right there on the line below the call. The community trades brevity for clarity. Errors are values, not side effects. Treat them like data.
Wrapping and unwrapping
Go 1.13 introduced error wrapping. You can add context to an error without losing the original type. This is done with fmt.Errorf and the %w verb.
import "fmt"
func fetchUser(id string) error {
// Validate the ID first.
err := validateID(id)
if err != nil {
// Wrap the error to add context about the operation.
return fmt.Errorf("fetch user %q: %w", id, err)
}
// Simulate success.
return nil
}
The wrapped error still satisfies the error interface. It also implements an Unwrap() error method that returns the underlying error. The errors package provides errors.Is and errors.As to work with wrapped chains.
errors.Is checks if any error in the chain matches a target. errors.As extracts a specific type from the chain. This lets you handle errors deep in a call stack without exposing internal types at every layer.
import "errors"
func main() {
err := fetchUser("")
if errors.Is(err, ErrInvalidInput{}) {
// Handle validation failure.
}
}
The errors.Is function walks the chain by calling Unwrap until it finds a match or hits nil. You can implement Is(error) bool on your type to customize matching logic, which is useful for errors that should match regardless of field values.
Pitfalls and compiler errors
Comparing errors by string is a common mistake. Error messages change during refactoring. Code that checks err.Error() == "not found" breaks when a developer tweaks the wording. Always use errors.Is or errors.As for programmatic checks. Strings are for humans; types are for machines.
Another trap is receiver choice. If your error type has a pointer receiver for Error(), you must return a pointer to satisfy the interface. If the method has a value receiver, both the value and pointer satisfy the interface. Mixing these up leads to confusing compiler errors.
type ErrBad struct{}
// Error has a pointer receiver.
func (e *ErrBad) Error() string {
return "bad"
}
If you try to return ErrBad{} here, the compiler rejects it with cannot use ErrBad{} (variable of struct type ErrBad) as error value in return: ErrBad does not implement error (missing Error method). The value type doesn't have the method; only the pointer does. Return &ErrBad{} instead, or switch to a value receiver if mutation isn't needed. Value receivers are preferred for errors unless the struct is large or you need to modify state.
Leaking sensitive data in Error() is another risk. The error string often ends up in logs. Never include passwords, tokens, or PII in the message. Keep the string safe for public consumption. Store sensitive details in private fields that only trusted code can access via errors.As.
Decision matrix
Pick the right error pattern based on how the caller needs to use the error.
Use errors.New when you define a sentinel error that gets returned repeatedly and compared with errors.Is. Sentinel errors are cheap, immutable, and perfect for well-known failure modes like "not found" or "closed".
Use a custom struct with Error() string when the caller needs to inspect details like an ID, a status code, or a retry flag. Structs let you attach data to the error and extract it later with errors.As.
Use fmt.Errorf with %w when you wrap an underlying error to add context while preserving the chain for errors.Is and errors.As. Wrapping builds a narrative of what went wrong without losing the root cause.
Use fmt.Errorf without %w when you want to hide the underlying error. This is rare and usually indicates a design flaw, but it's useful when the internal error contains sensitive information or implementation details that shouldn't leak.
Use a pointer receiver for Error() only when the error type holds large data or needs to be mutated. Value receivers are simpler and safer for most error types.