Introduction
Error handling in Go is often dismissed as boilerplate by developers coming from languages with try-catch blocks. But in Go, errors are values, and that philosophy shapes how we build reliable production systems. This guide on Go error handling walks you through the patterns that let us catch bugs early, debug faster, and ship code we trust.
Over seven years of building APIs at scale—from e-commerce checkouts to real-time trading systems—we've learned that how we handle errors matters as much as the happy path. The difference between a support incident at 2 AM and a graceful degradation often comes down to error context and explicit handling.
Check every error — if err != nil is idiomatic, not noise
The most common pushback we hear is: "Why do I have to write if err != nil so many times?" The answer is simple: errors in Go are return values, not exceptions. They flow through your code like any other value, and ignoring them is a choice you make explicitly.
In production, we don't ignore database failures, network timeouts, or file system errors. We handle them. And Go forces us to be intentional about it—which is a feature, not a bug.
Consider a typical handler that fetches an order, loads related items, and charges a payment:
func processOrder(ctx context.Context, orderID string) error {
order, err := fetchOrder(ctx, orderID)
if err != nil {
return err
}
items, err := loadItems(ctx, order.ItemIDs)
if err != nil {
return err
}
err = chargePayment(ctx, order.ID, order.Total)
if err != nil {
return err
}
return nil
}Each if err != nil is a checkpoint. At each one, we ask: "Can the operation continue?" If not, we stop and return the error up the stack. This makes the control flow explicit and auditable.
Without these checks, a payment failure would silently continue, marking an order as processed when it wasn't charged. That's a production nightmare. Explicit error checking prevents it.
Wrap with context — fmt.Errorf with %w keeps the cause
Returning an error unchanged loses crucial context. When a database query fails, returning that raw error doesn't tell the caller which order, which table, or at what stage of the transaction. That's when error wrapping comes in.
Go 1.13 added the %w verb to fmt.Errorf, which wraps the original error while adding context. This is the pattern we use everywhere in production:
func loadOrder(ctx context.Context, id string) (*Order, error) {
row := db.QueryRowContext(ctx, "SELECT * FROM orders WHERE id = $1", id)
var order Order
err := row.Scan(&order.ID, &order.Status, &order.Total)
if err != nil {
return nil, fmt.Errorf("load order %s: %w", id, err)
}
return &order, nil
}The error message now reads: "load order abc123: database/sql: connection lost". The caller knows which order failed and why. In a trading system, this might be the difference between retrying a failed position load and flagging a data integrity issue.
Wrapping preserves the original error (via the %w verb), so error checking code downstream can still inspect it. That's critical for patterns like errors.Is and errors.As, covered next.
We apply this at every layer of the stack: handlers wrap service errors, services wrap repository errors, repositories wrap database driver errors. Each level adds a breadcrumb trail that makes debugging faster when things go wrong.
errors.Is and errors.As — Check for specific errors through wrapping
Once errors are wrapped, we need a way to inspect them. The errors package provides two tools: errors.Is and errors.As.
errors.Is checks whether a specific error (or a wrapped version of it) is in the chain:
order, err := loadOrder(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Order not found, return 404
return writeJSON(w, 404, map[string]string{"error": "not found"})
}
// Some other database error, return 500
return writeJSON(w, 500, map[string]string{"error": "server error"})
}This works even though the original error was wrapped multiple times. The errors.Is function walks the chain, checking each layer.
errors.As extracts a specific error type from the chain, useful when you need details from a custom error:
order, err := loadOrder(ctx, id)
if err != nil {
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// Handle validation-specific logic
return writeJSON(w, 400, validationErr.Fields)
}
return writeJSON(w, 500, map[string]string{"error": "server error"})
}In a ride-hailing system, we might check for a RideNotAvailableError and retry with a backoff, but treat a PaymentFailedError differently. errors.As lets us do that cleanly.
The key rule: always wrap with %w when adding context, so the original error type remains accessible to upstream code.
Sentinel and typed errors — ErrNotFound for simple cases; custom types for details
For simple, recurring errors, we define sentinel errors at the package level. These are error values, not types, and they represent specific conditions:
package orders
var (
ErrNotFound = errors.New("order not found")
ErrAlreadyPaid = errors.New("order already paid")
ErrInvalidStatus = errors.New("invalid order status")
)Then in business logic:
func loadOrder(ctx context.Context, id string) (*Order, error) {
row := db.QueryRowContext(ctx, "SELECT * FROM orders WHERE id = $1", id)
var order Order
err := row.Scan(&order.ID, &order.Status, &order.Total)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("fetch order %s: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("fetch order %s: %w", id, err)
}
return &order, nil
}Callers can now do:
order, err := loadOrder(ctx, id)
if err != nil {
if errors.Is(err, orders.ErrNotFound) {
// Return 404
}
}For errors that need to carry extra data—validation failures with field names, rate limits with retry-after values—we use custom types:
type ValidationError struct {
Fields map[string]string
}
func (e *ValidationError) Error() string {
return "validation failed"
}
type RateLimitError struct {
RetryAfter time.Duration
}
func (e *RateLimitError) Error() string {
return fmt.Sprintf("rate limited, retry after %v", e.RetryAfter)
}In a CRM system, this lets handlers extract validation details and return them to the client in a structured format. In a SaaS API, a rate limit error can include the retry-after header.
The pattern: use sentinels for simple conditions, custom types when you need to pass data. Wrap both with context as they propagate up the stack.
Common mistakes
Mistake 1: Ignoring errors silently. We've seen code that calls _ = someFunc() to suppress errors without good reason. If a function returns an error, there's usually a reason. If you genuinely don't care, add a comment explaining why.
Mistake 2: Wrapping errors without %w. Using fmt.Errorf("error: %v", err) instead of %w breaks error type checking downstream. Always use %w when wrapping.
Mistake 3: Adding too much context at once. Each layer should add one piece of context. "failed to process order: failed to charge payment: failed to call payment gateway: connection timeout" is hard to read. Better: each layer wraps once, building a chain.
Mistake 4: Using errors.Is or errors.As on unwrapped errors. If you return a raw error without wrapping, the upstream code can't use these functions reliably. Always wrap with context.
Key takeaways
- Check every error—
if err != nilis idiomatic and non-negotiable in production code. - Wrap errors with
fmt.Errorf("context: %w", err)to build a readable error chain. - Use
errors.Isto check for specific errors through wrapped chains. - Use
errors.Asto extract custom error types and access their fields. - Define sentinel errors for simple, recurring conditions; use custom types when errors carry data.
- Test error paths as thoroughly as happy paths—they're where reliability lives.






Comments
Loading comments…