Go Error Handling

Go has no exceptions for ordinary failures. Functions that can fail return an error as their last value, and callers check it explicitly with if err != nil. That makes error paths visible in the code: you can see exactly where each failure is handled, wrapped, or passed up.

The criticism is verbosity; the payoff is clarity. Modern Go adds real tools on top of the basic pattern. Wrapping builds a chain of context, errors.Is and errors.As inspect that chain, and errors.Join combines several failures. Used well, Go errors read like a precise trail from the symptom back to the cause.

TL;DR

Quick Example

Core Concepts

Errors Are Values

Anything with an Error() string method is an error. Because errors are values, you can store them, compare them, wrap them, and pass them around like any other data. There's no hidden control flow.

Creating Errors

Wrapping and Unwrapping

Each layer adds context about what it was doing, producing messages like:

%w records the wrapped error so it's still reachable. %v formats the message only and cuts the chain. Use %v deliberately when you don't want callers depending on an implementation detail (for example, hiding which database driver you use).

errors.Is and errors.As

Never compare wrapped errors with == or match on err.Error() strings. Both break as soon as someone adds context.

Joining Errors

errors.Join(err1, err2, ...) (Go 1.20+) combines several errors into one. Is and As match any of them. It's useful for validation that collects every problem, or for cleanup where several Close() calls might fail.

Panic and Recover

panic unwinds the stack and crashes the program unless a deferred recover() catches it. Reserve it for impossible states (a nil dependency that should have been injected, an invalid regex literal in regexp.MustCompile) and for truly unrecoverable initialization errors. HTTP servers recover panics per request so one bug doesn't take down the process, but library code should return errors, not panic across API boundaries.

Designing Error APIs

Best Practices

Handle or Return, Not Both

Log an error or return it, not both. Logging at every layer produces the same failure five times in your logs. Return errors up to a boundary (HTTP handler, job runner, main) and log them once there, with full context.

Check Errors Immediately

Handle err right after the call that produced it, and keep the happy path unindented ("line of sight"): return early on error rather than nesting success logic inside else blocks.

Don't Ignore Errors Silently

_ = f.Close() is sometimes right, but make it a deliberate choice. Linters like errcheck (in golangci-lint) flag unchecked errors. For writes, Close() can report data loss, so check it.

Use context Errors Properly

Cancellation and timeouts surface as context.Canceled and context.DeadlineExceeded. Check them with errors.Is and usually treat them differently from real failures: don't alert on client disconnects.

Common Mistakes

Losing the Original Error

Returning a Typed Nil as an Error

An interface value is nil only if both its type and value are nil. This is a classic Go gotcha.

Matching on Error Strings

strings.Contains(err.Error(), "not found") breaks when messages change or get wrapped. Export a sentinel or type and use errors.Is/errors.As.

FAQ

Why doesn't Go have exceptions?

The designers wanted error handling to be explicit and local. Exceptions create invisible control flow, where any call might jump elsewhere. Go makes failure part of a function's signature and forces callers to decide what to do at each step. panic exists but is reserved for exceptional, usually unrecoverable situations.

When should I use %w vs %v?

Use %w when callers may legitimately need to inspect the underlying error, like sql.ErrNoRows or os.ErrNotExist. Use %v when the underlying error is an implementation detail you don't want to become part of your API contract.

Should I create custom error types?

When callers need structured information, such as an HTTP status, the invalid field, or whether a retry is safe, yes. When they only need to recognize a condition, a sentinel var ErrX = errors.New(...) is simpler. For everything else, a wrapped fmt.Errorf is enough.

How do I get stack traces?

Standard errors don't carry stack traces; wrapping with context usually makes the path clear enough. If you need them, some error libraries capture stacks at creation, and structured logging with log/slog plus request IDs often provides better debugging context than raw traces.

Related Topics

References