Right, let’s talk about error. It’s the one interface you’ll use more than any other, and its design is so stupidly simple it’s almost offensive. Here’s the entire definition, straight from the source:

type error interface {
    Error() string
}

That’s it. No GetMessage(), no GetStatusCode(), no GetUnderlyingCause(). Just a single method that returns a string. When the Go designers landed on this, I imagine there were high-fives all around. They had achieved maximum simplicity. It’s brilliant because it’s minimal, and it’s infuriating for the exact same reason. But before we get mad, let’s understand the genius in the constraint.

This minimalism forces a critical separation of concerns. An error, in its purest form, is just a message for a human. It’s not a control flow mechanism for your code. Your code shouldn’t be making decisions based on the text of an error message. That’s like trying to drive by looking at the road through a telescope. You need structured data for logic, and that’s not the job of the base error interface.

The Ubiquitous errors.New

So how do you make one of these things? The most straightforward way is with the errors package.

package main

import (
    "errors"
    "fmt"
)

func checkFuel(level int) error {
    if level < 10 {
        return errors.New("fuel level critically low: engage panic")
    }
    return nil
}

func main() {
    err := checkFuel(5)
    if err != nil {
        fmt.Println("Mission control, we have a problem:", err.Error())
        // Which is identical to just:
        // fmt.Println("Mission control, we have a problem:", err)
    }
}

This prints: Mission control, we have a problem: fuel level critically low: engage panic

errors.New(string) gives you a basic, immutable error value. It’s a workhorse. Use it for simple, static error messages. Notice how we just printed err directly? That’s because the fmt package is smart enough to call the .Error() method on anything that implements the interface. You almost never need to call .Error() yourself; just pass the error around and print it.

fmt.Errorf and The Power of Wrapping

But what if you need to add some context? You could do some string concatenation, but that creates a new, unrelated error. The original context is lost. This is where fmt.Errorf and the %w verb become your best friends. This is Go’s built-in mechanism for error wrapping.

func startEngine(serialNumber int) error {
    err := checkFuel(5)
    if err != nil {
        return fmt.Errorf("failed to start engine %d: %w", serialNumber, err)
    }
    return nil
}

func main() {
    err := startEngine(12)
    if err != nil {
        fmt.Println(err)
        // Prints: failed to start engine 12: fuel level critically low: engage panic
    }
}

See what happened? We didn’t just have the low fuel error; we now know which engine we failed to start. The %w verb wraps the original error inside a new one. This creates a chain of errors that we can later interrogate using the errors package helpers like errors.Is and errors.As (which we’ll get to in the next section). This is the idiomatic way to add context as an error bubbles up the call stack. Always use %w when you’re adding context to a returned error. Using %v instead is a common pitfall; it does the formatting but discards the original error, breaking the chain and making your life much harder when debugging.

Creating Your Own Error Types

Sometimes, a string isn’t enough. What if your code needs to react to a specific error condition? This is where you break out of the mold and create your own custom type that implements error.

type InvalidInputError struct {
    FieldName string
    InputValue string
}

func (e *InvalidInputError) Error() string {
    return fmt.Sprintf("invalid input for field '%s': received '%s'", e.FieldName, e.InputValue)
}

func validateInput(field, value string) error {
    if value == "" {
        return &InvalidInputError{FieldName: field, InputValue: value}
    }
    return nil
}

func main() {
    err := validateInput("username", "")
    if err != nil {
        fmt.Println(err) // Prints: invalid input for field 'username': received ''
        // But now we can also check the type to handle it programmatically.
    }
}

This is the real power move. You get a human-readable message from .Error() for logging, but you also have a structured type with fields (FieldName, InputValue) that your code can use to make intelligent decisions. The key is that your logic checks the type, not the string. This is robust, refactorable, and doesn’t break if someone tweaks the wording of the error message for clarity.

The beauty of the simple error interface is that all these approaches—a basic errors.New, a wrapped error from fmt.Errorf, and a complex custom struct—are all treated exactly the same by the calling code. It just sees an error. This simplicity is the foundation upon which all of Go’s error handling is built, for better or worse.