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 error value — via errors.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.Is and errors.As let 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 != nil immediately 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.Is to test for a specific sentinel error and errors.As to 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-level vars created with errors.New so callers elsewhere can compare against them with errors.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 an error.
  • Keep error messages lowercase and without trailing punctuation, per Go convention, since they’re often embedded inside other wrapped messages.
  • Never use panic for 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 using strconv.Atoi and 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 RangeError with Min, Max, and Value fields and an Error() method describing the violation. Write a function that returns it, then use errors.As in main to 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 via fmt.Errorf. Confirm with errors.Is that both cases are detected the same way.

Summary

  • The error type 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 with fmt.Errorf, and structured ones with a custom type that implements Error().
  • Use %w in fmt.Errorf to wrap an underlying error while adding context, preserving the chain.
  • Use errors.Is to check for a specific sentinel error and errors.As to 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 error interface is not itself equal to nil.