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
erroris an interface with one method:Error() string. Errors are ordinary values.- Return errors as the last result; check them immediately with
if err != nil. - Wrap with context using
fmt.Errorf("loading config: %w", err), which preserves the original for inspection. - Use
errors.Is(err, target)to match sentinel errors anderrors.As(err, &target)to extract typed errors, anywhere in the chain. - Define sentinel errors (
var ErrNotFound = errors.New(...)) or custom types for errors callers need to react to. panicis for programmer bugs and truly unrecoverable states, not for expected failures.
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
errors.New("message")creates a simple error.fmt.Errorf("context: %v", x)formats a message. With%w, it also wraps an underlying error.- A custom type carries structured data: fields such as status code, field name, or retryability.
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
errors.Is(err, ErrNotFound)walks the wrap chain comparing each error to the target (or calling itsIsmethod). Use it for sentinel values.errors.As(err, &target)walks the chain looking for an error assignable totarget's type, and setstargetwhen found. Use it for typed errors carrying data.
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
- Expose what callers act on. If callers need to distinguish "not found" from "conflict", export sentinels or types for those. Everything else can be an opaque wrapped error.
- Name conventions: sentinel variables start with
Err(ErrNotFound); error types end withError(ValidationError). - Keep messages lowercase with no trailing punctuation, since they get concatenated:
"opening file: permission denied". - Add context once per layer: what this function was trying to do, plus relevant identifiers. Don't repeat what the wrapped error already says.
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
- Go — The language overview
- Error Handling — Strategies across languages
- Go Interfaces — The
errorinterface and the typed-nil gotcha - Go Testing — Asserting on errors in tests
- Rust Error Handling — Result types, a close relative of Go's approach