The moment something goes wrong
You are writing a function that parses a JSON payload from an HTTP request. The payload might be missing a required field. The field might contain the wrong data type. The network connection might drop mid-read. In languages with exceptions, you would wrap the operation in a try block and let the runtime unwind the stack until a catch handler intercepts the failure. Go does not have exceptions. Go treats failures as ordinary values that flow through your return statements. Those values must implement the error interface. Once you understand how that interface works, you understand the foundation of Go reliability.
What the error interface actually is
The error interface lives in the standard library. It contains exactly one method: Error() string. That is the complete contract. Any type that defines a method with that exact signature automatically satisfies the interface. Go uses structural typing, so you never declare that a type implements an interface. You simply write the method, and the compiler connects the dots at compile time.
Think of it like a standardized power connector. The appliance does not care what brand manufactured the plug. It only cares that the plug has two flat prongs spaced at a specific distance. The Go runtime only cares that your type can produce a human-readable string when the Error method is called. The method name and return type are the prongs. Everything else is implementation detail.
The smallest possible custom error
Here is the simplest custom error you can write. It holds a single piece of context and returns a formatted message when the interface method is invoked.
package main
import "fmt"
// MissingKeyError represents a configuration lookup failure.
type MissingKeyError struct {
key string
}
// Error satisfies the error interface.
func (e MissingKeyError) Error() string {
// Format a clear message for the caller.
return fmt.Sprintf("config key not found: %s", e.key)
}
func main() {
// Instantiate the custom error and assign it to the error type.
err := MissingKeyError{key: "database_url"}
fmt.Println(err)
}
The interface contract is satisfied automatically. You do not need to register the type anywhere. Build the program and the compiler validates the method signature against the interface definition.
How the compiler and runtime handle it
When you run the program, fmt.Println receives the MissingKeyError value. The fmt package checks whether the value implements the error interface. It does, so fmt calls the Error method behind the scenes. The method formats the string and returns it. The output is config key not found: database_url.
Notice the receiver is a value receiver: (e MissingKeyError). Go copies the struct when you pass it around. That is perfectly fine for small structs containing a few strings or integers. If your error holds a large byte slice or a nested map, switch to a pointer receiver (e *MissingKeyError) to avoid copying memory. The interface contract remains identical.
Go's method set rules determine which types satisfy an interface. A value method set includes both value and pointer receivers. A pointer method set only includes pointer receivers. If you define the method with a pointer receiver, only *MissingKeyError satisfies the interface. The plain struct does not. This distinction trips up many developers. The compiler will reject the assignment with MissingKeyError does not implement error (Error method has pointer receiver) if you try to pass a value instance to a function expecting an error.
You do not need to write explicit interface assertions in production code. The compiler handles it. Some teams add a compile-time guard like var _ error = MissingKeyError{} at the top of a file. That line forces the compiler to verify the contract during every build. It is optional but useful in large codebases where method signatures change frequently.
Errors are values. Pass them like any other return type.
Adding context to real-world errors
Production systems rarely return bare strings. They return errors that carry structured context, error codes, or retry flags. The standard library provides fmt.Errorf with the %w verb to wrap existing errors. Wrapping preserves the original error while adding a new layer of context. You can later unwrap it using errors.Is or errors.As.
Here is how a custom error type fits into a realistic validation flow. The function checks business rules, creates a custom error, and wraps it with call-site context.
package main
import (
"errors"
"fmt"
)
// ValidationError represents a business rule failure.
type ValidationError struct {
field string
value string
}
// Error satisfies the error interface.
func (e ValidationError) Error() string {
// Format the validation failure for the caller.
return fmt.Sprintf("invalid %s: %q", e.field, e.value)
}
// ValidateEmail checks format constraints and returns a wrapped error.
func ValidateEmail(email string) error {
if email == "" {
// Wrap the custom error to preserve the chain.
return fmt.Errorf("registration failed: %w", ValidationError{field: "email", value: email})
}
return nil
}
func main() {
err := ValidateEmail("")
if err != nil {
// Unwrap to check for the specific custom type.
var ve ValidationError
if errors.As(err, &ve) {
fmt.Printf("caught validation error for field: %s\n", ve.field)
}
}
}
The errors.As function walks the error chain automatically. It stops when it finds a type that matches the target pointer. This pattern replaces the old practice of type switching on every error. You keep the original error intact, add context at each layer, and extract structured data only when you need it.
The community convention for error messages is lowercase, no trailing punctuation, and no capitalization unless the message starts with a proper noun. The fmt package respects this when you use %w. Your custom Error method should follow the same rule. Consistency matters when errors bubble up to logs and monitoring dashboards.
Context is plumbing. Run it through every long-lived call site.
Common mistakes and compiler feedback
Beginners often try to return a string directly instead of an error type. The compiler rejects this immediately. If you write return "something failed" in a function that promises error, you get cannot use "something failed" (untyped string constant) as error value in return argument. Strings do not implement the error interface because they lack the Error() string method.
Another frequent mistake is defining the method with the wrong capitalization. Go requires interface methods to be exported if the interface itself is exported. The error interface is exported, so your method must be Error, not error. If you lowercase it, the compiler says ValidationError does not implement error (missing error method). The fix is always capitalization.
You might also forget to import the errors package when using errors.As or errors.Is. The compiler complains with undefined: errors. Add the import and the code compiles. The errors package is part of the standard library, so no external dependencies are required.
Error handling in Go looks verbose. You will write if err != nil { return err } dozens of times. That is intentional. The language designers made the unhappy path explicit. You cannot accidentally swallow an error by forgetting a try-catch block. The boilerplate forces you to acknowledge every failure point.
Trust the explicit check. Write the boilerplate.
When to build your own error type
Use a plain fmt.Errorf string when the error is a one-off and you never need to check for it programmatically. Use a custom struct type when you need to attach structured data like error codes, field names, or retry flags. Use fmt.Errorf with %w when you want to preserve the original error chain for errors.Is and errors.As checks. Use a pointer receiver for your Error method when the struct is large or you plan to mutate it before returning. Use a value receiver when the struct is small and immutable. Stick to the standard library errors.New for simple sentinel errors that you compare with errors.Is.
Errors are values. Treat them like data, not exceptions.