The Error Interface
In Go, errors are not exceptions — they are ordinary values of a type called error. That type is itself an interface, which is why almost every function that can fail returns an error as its last result. Understanding the error interface is the key to understanding how error handling works throughout the entire language and standard library.
Overview / How it works
The error type is a predeclared interface built into the language. It is defined, conceptually, as:
type error interface {
Error() string
}
That is the entire interface: one method, Error() string, which returns a human-readable description of what went wrong. Because Go interfaces are satisfied implicitly — there is no implements keyword — any type that has a method named Error with that exact signature automatically counts as an error, without ever mentioning the error interface by name. This is different from Java or C#, where a class must explicitly declare which interfaces it implements. In Go, the compiler checks the method set structurally, at compile time, and if it matches, the type satisfies the interface.
This design is also why Go does not use exceptions for routine failure handling. A function that can fail simply returns an extra value of type error alongside its normal result. The caller is expected to check it immediately with if err != nil. This makes control flow explicit and visible in the code — you can always see, at the call site, that a call might fail and what happens if it does. panic and recover exist in Go, but they are reserved for truly exceptional situations (like a programming bug that leaves the program in an unrecoverable state), not for everyday error handling like a missing file or invalid user input.
Under the hood, an error value is an interface value, which in Go is a two-word structure: a pointer to type information (which concrete type is stored) and a pointer to the actual data. When you write errors.New("boom"), Go allocates a small unexported struct that holds the string "boom" and wraps a pointer to it inside the error interface. When you write your own type with an Error() string method, the same thing happens: the interface value holds the type information for your struct plus a pointer (or copy) of your struct’s data. This two-part representation is also the source of one of the most notorious Go gotchas, the “typed nil” trap, covered later in Common Mistakes.
Syntax
There are three common ways to produce an error value:
| Approach | Example | When to use |
|---|---|---|
errors.New |
errors.New("invalid input") |
A simple, static error message with no dynamic data |
fmt.Errorf |
fmt.Errorf("parsing %q: %w", name, err) |
A formatted message, optionally wrapping an underlying error with %w |
| Custom type | type MyError struct{ ... } with an Error() string method |
You need to attach structured data (codes, fields) that callers can extract later |
Examples
Example 1: A basic error with errors.New
package main
import (
"errors"
"fmt"
)
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Result:", result)
}
Output:
Error: division by zero
The call to divide(10, 0) returns a zero-valued float and a non-nil error created by errors.New. The caller checks err != nil immediately, prints the message, and returns early — the normal Go pattern for handling failure.
Example 2: A custom error type
package main
import (
"fmt"
)
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Msg)
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Msg: "must not be negative"}
}
return nil
}
func main() {
err := validateAge(-5)
if err != nil {
fmt.Println(err)
}
}
Output:
validation failed on field "age": must not be negative
*ValidationError satisfies error simply by having an Error() string method — nothing in the type declaration mentions error at all. Because Error is defined on the pointer receiver *ValidationError, validateAge returns &ValidationError{...}, not a plain value. When fmt.Println prints an error, it automatically calls its Error() method to get the string.
Example 3: Wrapping and inspecting errors
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("item not found")
func findItem(id int) error {
if id != 1 {
return fmt.Errorf("findItem(%d): %w", id, ErrNotFound)
}
return nil
}
type ValidationError struct {
Field string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid field: %s", e.Field)
}
func main() {
err := findItem(42)
if errors.Is(err, ErrNotFound) {
fmt.Println("wrapped error is ErrNotFound")
}
fmt.Println("full error:", err)
wrapped := fmt.Errorf("processing: %w", &ValidationError{Field: "name"})
var ve *ValidationError
if errors.As(wrapped, &ve) {
fmt.Println("extracted field:", ve.Field)
}
}
Output:
wrapped error is ErrNotFound
full error: findItem(42): item not found
extracted field: name
The %w verb in fmt.Errorf is special: it wraps the given error inside the new one, preserving a chain back to the original. errors.Is walks that chain looking for a match against a specific sentinel error (here, ErrNotFound), even though the top-level error is really a different string. errors.As walks the same chain looking for an error of a specific concrete type, and if found, copies it into the target pointer so you can access its fields (ve.Field).
How it works step by step
- A function encounters a failure condition and constructs an
errorvalue — viaerrors.New,fmt.Errorf, or a custom type’s constructor. - That value is returned as the last result of the function, alongside a zero-valued (often unusable) result for the other return value(s).
- The caller immediately checks
if err != nil. This is a plain interface comparison against nil — it is true if and only if the interface value has no type and no data set. - If the error needs more context on its way up the call stack, the caller wraps it with
fmt.Errorf("...: %w", err)rather than discarding the original — this preserves the full chain for later inspection. - Eventually, some caller decides what to do with the error: log it, retry, return a fallback, or propagate it further.
errors.Isanderrors.Aslet that caller test the chain without needing to know every intermediate wrapping layer.
Common Mistakes
Mistake 1: Discarding the error return
Wrong:
data, _ := os.ReadFile("config.txt")
fmt.Println(string(data))
Using _ to discard the error means a missing or unreadable file silently produces an empty string instead of a clear failure — bugs like this are painful to track down later. Always check the error:
data, err := os.ReadFile("config.txt")
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
Mistake 2: The typed-nil interface trap
Wrong:
type MyError struct{}
func (e *MyError) Error() string { return "my error" }
func doSomething() error {
var err *MyError // nil pointer, never assigned
return err // BUG: returns a non-nil error interface!
}
func main() {
err := doSomething()
if err != nil {
fmt.Println("got error:", err) // prints, even though nothing failed
}
}
This compiles and runs, but surprises everyone the first time they hit it. err is declared as *MyError and never assigned, so it is a nil pointer. But when it’s returned as an error, Go packages it into an interface value that has type information (*MyError) and a nil data pointer. That interface value is not equal to nil — only an interface with no type information at all equals nil. The fix is to return the untyped literal nil directly when there is no error, never a nil-valued concrete pointer stored in the error result:
func doSomething() error {
var err *MyError
if err != nil {
return err
}
return nil // return the bare nil, not the typed pointer
}
Mistake 3: Comparing wrapped errors with ==
Wrong:
if err == ErrNotFound {
// handle not-found case
}
This only works if err is exactly ErrNotFound. The moment any code wraps it with fmt.Errorf("...: %w", ErrNotFound), the direct comparison silently fails because err is now a different value that merely wraps the sentinel. Use errors.Is, which walks the wrap chain:
if errors.Is(err, ErrNotFound) {
// handle not-found case, even if wrapped
}
Best Practices
- Always check
err != nilimmediately after a call that returns an error — don’t let it flow further unchecked. - Wrap errors with
fmt.Errorf("context: %w", err)as they move up the stack so the final message tells a full story, but keep the original error inspectable. - Use
errors.Isto test for a specific sentinel error anderrors.Asto extract a specific concrete error type — avoid raw==comparisons or type assertions on errors that might be wrapped. - Export sentinel errors (like
ErrNotFound) as package-levelvars created witherrors.Newso callers elsewhere can compare against them witherrors.Is. - Give custom error types a pointer receiver
Error()method when the struct is more than a few small fields, and return*YourError, never a bare nil concrete pointer, as anerror. - Keep error messages lowercase and without trailing punctuation, per Go convention, since they’re often embedded inside other wrapped messages.
- Never use
panicfor routine, expected failure conditions — reserve it for programmer errors and unrecoverable states.
Practice Exercises
- Write a function
parsePositive(s string) (int, error)that converts a string to an int usingstrconv.Atoiand returns an error (wrapped with%w) if the string isn’t a valid number or if the resulting number is negative. - Define a custom error type
RangeErrorwithMin,Max, andValuefields and anError()method describing the violation. Write a function that returns it, then useerrors.Asinmainto extract and print the fields. - Create a sentinel error
ErrPermissionDenied. Write two functions, one that returns it directly and one that wraps it with extra context viafmt.Errorf. Confirm witherrors.Isthat both cases are detected the same way.
Summary
- The
errortype is a built-in interface with a single method,Error() string— any type with that method satisfies it implicitly. - Create simple errors with
errors.New, formatted ones withfmt.Errorf, and structured ones with a custom type that implementsError(). - Use
%winfmt.Errorfto wrap an underlying error while adding context, preserving the chain. - Use
errors.Isto check for a specific sentinel error anderrors.Asto extract a specific error type, both of which understand wrapped chains — plain==and type assertions do not. - Watch out for the typed-nil interface trap: a nil concrete pointer stored in an
errorinterface is not itself equal tonil.
