← Engineering Notes

Error Handling Strategies

Go and Backend Engineering · last updated Oct 2026

Concept

In Go, errors are values. Functions that can fail return an error as their last return value. The caller receives the error and decides what to do with it — return it, wrap it with context, log it, or handle it. There is no implicit exception propagation.

This is a deliberate design choice. It makes error handling explicit and local. Every call site that can fail must address the failure.

Problem

Explicit error handling can become noisy. The pattern if err != nil { return err } repeats throughout Go code. The challenge is handling errors in a way that is explicit and informative without becoming boilerplate.

A deeper problem is distinguishing between different kinds of errors: errors the caller should handle differently (a record not found vs a database connection failure), errors that should be logged vs errors that should be returned to the user, and errors that indicate a bug vs errors that are expected operating conditions.

How it works

Wrapping errors with context. The fmt.Errorf function with the %w verb wraps an error, adding context while preserving the original for inspection:

if err := db.GetUser(id); err != nil {
    return fmt.Errorf("get user %d: %w", id, err)
}

The caller can then unwrap the error to inspect it: errors.Is(err, sql.ErrNoRows) returns true even if the error has been wrapped multiple times.

Sentinel errors. For expected conditions, define package-level error values that callers can check:

var ErrAccountNotFound = errors.New("account not found")

// caller:
if errors.Is(err, ErrAccountNotFound) {
    // return 404
}

Custom error types. When an error needs to carry structured data — an HTTP status code, a field name, a constraint violation — define a concrete type that implements the error interface:

type ValidationError struct {
    Field   string
    Message string
}

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

Trade-offs

Wrapping every error with context helps debugging but adds verbosity. A common strategy: wrap errors as they cross package or layer boundaries, but not at every internal call site within a package.

Sentinel errors create coupling between packages. If a lower-level package changes its error values, callers break. Custom error types with errors.As and interface checks can be more flexible.

The distinction between an error that should be logged and one that should be returned to the caller matters. A database connection failure is an internal error — log it, return a generic 500. A record not found is a normal condition — return a 404 without logging.

Practical application

In Simple Bank, database errors are inspected to distinguish between "not found" (return 404) and genuine failures (return 500). The pq library returns PostgreSQL error codes as typed errors, so unique constraint violations can be identified and returned as 409 conflicts rather than internal errors.

In GoTP, errors from the SMS provider are wrapped and logged but not exposed to the caller. The API returns a generic error message, while the detailed error is available in structured logs for debugging.

My understanding

The explicit error handling in Go felt like overhead at first. Coming from languages with exceptions, returning errors from every function seemed repetitive. Over time I've come to appreciate it: you can't accidentally ignore a failure. Every call site makes a deliberate decision about what to do with an error.

The most useful habit I've developed: always add context when returning an error. return err loses the where. return fmt.Errorf("create account: %w", err) preserves the original and tells you what operation was in progress. This makes reading stack traces and logs significantly easier.

← Back to Engineering Notes