Skip to content

Go Error Handling: The Patterns I Actually Reach For

Go Error Handling: The Patterns I Actually Reach For

I lost an afternoon last week to a single line of code. The line was return err. Not a panic, not a stack trace, just a 500 in production and a log entry that said record not found with nothing else. Which record? Which of the four database calls in that handler? I’d written the thing myself eight months earlier and felt pretty good about how clean it looked. Past me had left present me a mystery with no clues.

Go’s error handling gets mocked a lot. The if err != nil block is basically a meme now. But after years of shipping Go services, my honest opinion is that the verbosity isn’t the real problem. The problem is that most of us treat an error as something to pass upward instead of something to describe. I did this for a long time. This post is the small set of patterns I actually use now, with the before and after code that changed how I write handlers.

The if err != nil block isn’t the enemy

Everyone wants to talk about how noisy Go errors are. Three extra lines after every call, over and over. I used to mind this. I don’t anymore.

Here’s the thing I missed for too long: those three lines are a place to add information, and most code throws that place away. Look at the version I used to write:

func getUser(id int) (*User, error) {
    row, err := db.Query(id)
    if err != nil {
        return nil, err
    }
    return parse(row)
}

That return nil, err is the bare minimum. It compiles, it passes review, and it tells you nothing when it fails. The block isn’t the problem. What I put inside it was.

Wrap errors with %w so you can read your logs

The fix that saved me the most time is also the smallest. Go 1.13 added the %w verb to fmt.Errorf, which wraps an error while keeping the original reachable. The Go team wrote it up on the official blog, and it’s the one feature I’d back-port to every codebase if I could.

Same function, one change:

func getUser(id int) (*User, error) {
    row, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("getUser %d: %w", id, err)
    }
    return parse(row)
}

Now the error that lands in my logs reads like a trail: getUser 4127: query timeout. If three layers each add their bit of context, I get a sentence that tells me where the request went and where it died. I stopped reaching for a debugger on half my production issues once I started doing this everywhere.

errors.Is and errors.As instead of string matching

Here’s a confession. For about two weeks early on, I checked error types like this:

if strings.Contains(err.Error(), "no rows") {
    return notFound()
}

It worked until someone reworded an error string and my check silently stopped matching. Wrapping breaks this approach completely, since the wrapped message has extra text around the original.

The errors package has the right tools for this, documented in the standard library reference. errors.Is walks the whole wrapped chain looking for a specific value:

row, err := db.Query(id)
if errors.Is(err, sql.ErrNoRows) {
    return nil, ErrUserNotFound
}

And errors.As pulls a specific error type out of the chain so you can read its fields:

var pqErr *pq.Error
if errors.As(err, &pqErr) {
    log.Printf("postgres code: %s", pqErr.Code)
}

Both of these keep working no matter how many layers of %w sit between the call and the check. That’s the payoff for wrapping properly.

Sentinel errors and custom types, and when each earns its place

A sentinel error is just a package-level value you compare against:

var ErrUserNotFound = errors.New("user not found")

I reach for these when callers need to branch on a known condition and don’t need extra detail. A login handler that wants to return 404 versus 500 only needs to know “was it not found.” errors.Is(err, ErrUserNotFound) answers that and nothing more.

When callers need data about the failure, I write a small type instead:

type ValidationError struct {
    Field string
    Msg   string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}

Now the API layer can pull the field name out with errors.As and build a useful response. The rule I follow: sentinel for “which case,” custom type for “which case plus details.” I don’t add a custom type until I actually need the fields, because a type nobody inspects is just a heavier errors.New.

errors.Join for the times one error isn’t enough

Sometimes a function fails in more than one way at once. Validating a form is the obvious case. I used to concatenate strings by hand, which was ugly and lost the individual errors. Go 1.20 added errors.Join, covered in the release notes, and it cleaned this up:

func validate(f Form) error {
    var errs []error
    if f.Email == "" {
        errs = append(errs, errors.New("email required"))
    }
    if f.Age < 18 {
        errs = append(errs, errors.New("must be 18+"))
    }
    return errors.Join(errs...)
}

The joined error prints each problem on its own line, and errors.Is still works against any of them. If the slice is empty, Join returns nil, so the happy path stays clean with no special case.

Where I stop wrapping

Wrapping has a cost the tutorials skip. When you wrap an error with %w, you expose it to your callers, and they can start depending on it. The Go team’s own error value FAQ makes this point: once an error is part of your public surface, changing it can break people. So inside a package, I wrap freely. At a public API boundary I think harder, and sometimes I deliberately return a fresh sentinel instead of leaking a database driver’s error to the outside world.

The other place I hold back is logging. If I’ve already logged an error with full context, I don’t also wrap and return it up the stack to be logged again. That’s how you get the same failure printed five times. I sort out logging boundaries the same way I sorted out structured logs, which I wrote about in my post on Go’s slog package. Decide who logs, and let everyone else just return.

What to do this week

Open your codebase and grep for return err. Not to fix every one, that’s a waste of a weekend. Find the five hottest paths, the handlers that page you at 3am, and give each return err a %w wrap with the function name and the key identifier. That alone turns most of your mystery logs into readable sentences. If you want to see how I structure the services I build around this, it’s in my work.

Go errors aren’t elegant. I’ve made peace with that. But treated as a place to write down what happened, the verbose little blocks earn their keep.