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.