Error Handling in Go That Stays Useful at the API Boundary
A practical Go error strategy for adding context, preserving stable meaning, mapping HTTP responses, logging once, and testing behaviour.
Go makes errors visible in ordinary control flow, but that does not automatically make them useful. Problems appear when every layer logs the same failure, callers compare strings, database errors leak into HTTP responses, or a wrapped implementation detail quietly becomes part of a package contract. I prefer a small error vocabulary, contextual wrapping inside the application, and one deliberate translation at the boundary.
Add context that answers what failed
An error such as connection refused is missing the operation and subject. Each layer should add context it uniquely knows: loading account 42, decoding the invoice response, or storing an idempotency record. I keep messages concise and start them with a lower-case operation so wrapped errors read as one useful chain.
Wrapping with %w preserves the underlying error for errors.Is and errors.As. That is useful when the caller is meant to react to it. It is also an API decision. If a repository exposes sql.ErrNoRows, callers can become coupled to database/sql. I usually translate that case to a domain-level ErrNotFound before it leaves the repository boundary.
var ErrNotFound = errors.New("not found")
func (r *Repository) Account(ctx context.Context, id string) (Account, error) {
account, err := queryAccount(ctx, r.db, id)
if errors.Is(err, sql.ErrNoRows) {
return Account{}, fmt.Errorf("account %s: %w", id, ErrNotFound)
}
if err != nil {
return Account{}, fmt.Errorf("query account %s: %w", id, err)
}
return account, nil
}Use Is for categories and As for useful detail
errors.Is answers whether an error chain has a particular meaning, such as not found, conflict, or context cancellation. errors.As retrieves a particular error type when the caller needs structured detail. Direct equality and type assertions only inspect the outer value and break as soon as another layer adds context.
I use sentinel errors for a small set of stable conditions with no extra fields. When a caller needs field-level validation problems, a retry time, or an upstream status, a typed error is clearer. I do not create a custom type for every failure. Most unexpected failures only need context and their original cause for internal diagnosis.
- errors.Is for stable categories
- errors.As for structured details
- %w only when callers may depend on the cause
- %v or translation when the cause is an implementation detail
- Never branch on error text
Keep HTTP knowledge out of domain code
A service method should not return an HTTP status code. It may be called from an HTTP handler today and a queue worker tomorrow. The handler owns the translation from application meaning to protocol: not found to 404, invalid input to 422 or 400 according to the API contract, conflict to 409, and an unexpected failure to 500.
The response body contains a stable public code and safe message, not err.Error(). Internal errors can contain table names, upstream URLs, identifiers, or implementation details. A request correlation ID connects the public response to the server log without exposing the chain to the client.
switch {
case errors.Is(err, ErrNotFound):
writeProblem(w, http.StatusNotFound, "account_not_found")
case errors.Is(err, ErrConflict):
writeProblem(w, http.StatusConflict, "account_conflict")
case errors.Is(err, context.Canceled):
return
default:
logger.ErrorContext(ctx, "update account failed", "error", err)
writeProblem(w, http.StatusInternalServerError, "internal_error")
}Treat cancellation as control flow
context.Canceled often means the caller disconnected or a parent operation stopped. context.DeadlineExceeded means a budget expired. They deserve metrics because repeated deadlines can reveal an unhealthy dependency, but they are not always application defects. Logging every client cancellation as an error fills dashboards with noise.
Cancellation only works if the same context reaches database queries, HTTP requests, and goroutines. If a lower layer returns the context error with useful operation context, errors.Is still lets the boundary classify it. Replacing that error with a new string loses the signal and makes shutdown behaviour harder to distinguish from a real failure.
Log once at the layer that can act
If a repository logs an error, the service logs it again, and the handler logs it a third time, one failed query becomes three alerts without additional information. Lower layers return context. The boundary that owns the request, job, or command logs the final unexpected error once with structured fields such as operation, request ID, job ID, and duration.
Expected outcomes usually do not need error-level logs. A missing optional record or a rejected validation request can be represented in normal metrics and access logs. Unexpected errors need the full wrapped chain, but secrets, credentials, complete request bodies, and personal data still stay out of the log fields.
Test the contract instead of the wording
Tests should assert errors.Is or errors.As and the observable boundary response. Exact string comparisons make harmless improvements to context look like breaking changes. I test that a wrapped ErrNotFound still becomes the expected public code, a validation type preserves its fields, and an unknown dependency failure never appears in the response body.
For package APIs, I document the error categories callers may inspect. Everything else is diagnostic context, not a compatibility promise. That small discipline keeps error handling useful even when storage, clients, or internal layers change.
Use this in practice
- Add operation and subject context to returned errors
- Expose only stable causes with %w
- Translate infrastructure errors into domain meaning
- Use errors.Is and errors.As instead of text matching
- Map errors once at the protocol boundary
- Log unexpected failures once with safe structured context
- Test categories and responses rather than exact wording
