Errors as Values: Go's Error Philosophy
Go rejects exceptions for expected failure paths.
Search across all documentation pages
Go rejects exceptions for expected failure paths.
Instead, functions return an error value next to their normal result, and callers decide immediately whether to propagate, wrap, log, or translate that failure.
That choice shapes readability, library boundaries, and how production services map domain problems to HTTP or gRPC responses.
error interface value returned like any other result - not a control-flow mechanism that unwinds the stack.catch blocks.error interface, sentinel errors, wrapping with %w, errors.Is / errors.As, panic vs error, fail-fast at boundaries.if err != nil checks add verbosity; deep call stacks need disciplined wrapping; there is no built-in retry or classification unless you design it.Before Go 1, the designers watched large C++ and Java codebases struggle with exceptions used for both expected and catastrophic failures.
Hidden control flow made it hard to know which functions could fail, and catch blocks far from the cause obscured remediation.
Go's answer is mechanical simplicity: a function signature tells you everything.
func ReadConfig(path string) (Config, error)If the second return is non-nil, the first value is usually unusable.
There is no try, no finally, and no implicit stack unwind for ordinary problems.
The error interface is intentionally tiny:
type error interface {
Error() string
}Anything with a string method satisfies it - sentinel variables, formatted messages, rich structs with extra fields.
Errors as values means you pass them around like int or string: store them, compare them with errors.Is, inspect types with errors.As, and attach context with fmt.Errorf("load config: %w", err).
Panic exists, but idiomatic Go reserves it for invariant violations and programmer mistakes (nil pointer dereference, index out of range).
Libraries should not panic on bad user input; applications may choose to crash on startup misconfiguration.
The error model interacts with Go's other explicit choices: multiple return values, no inheritance, and package-level APIs.
A low-level package like os exports sentinel errors (os.ErrNotExist) and operation-specific wrappers.
Middleware and handlers sit at boundaries - HTTP handlers, gRPC interceptors, CLI main - where errors become status codes, exit codes, or structured log fields.
Between those layers, code usually wraps errors to add context while preserving the root cause for errors.Is and errors.As.
caller ──calls──> library ──returns──> (T, error)
│ │
│ if err != nil │
├─ wrap with %w ───────────────────────┘
├─ log + return
└─ map to client response at boundary
Wrapping (Go 1.13+) stores an unwrap chain.
errors.Is(err, target) walks that chain for sentinel equality.
errors.As(err, &target) finds the first error value assignable to a pointer type.
This replaces fragile string matching and type assertions on every layer.
Libraries should return stable, documented errors; applications should not leak raw os errors to external clients without translation.
The context package adds cancellation (context.Canceled, context.DeadlineExceeded) as first-class errors that propagate through the same (T, error) pattern.
At scale, teams adopt error policies: naming conventions (Err prefix for sentinels), whether to export error types, and when to use %w vs %v (wrap vs replace chain).
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
Sentinel var ErrX = errors.New(...) | Simple errors.Is checks | Grows unwieldy with many variants | Small domain vocabularies |
| Custom struct types | Rich fields (HTTP code, retryable) | Callers must use errors.As | Service boundaries, SDKs |
Opaque errors + Unwrap | Stable public API | Less inspectable for callers | Public libraries |
panic + recover | Stops goroutine on invariant break | Easy to misuse across API edges | HTTP middleware, template engines |
Observability hooks often live on custom types: Retryable() bool, LogLevel() slog.Level, or gRPC status details.
Go 1.20+ errors.Join merges multiple failures (useful in validation and parallel fan-in).
For concurrent code, errors still return on channels or through errgroup; the value philosophy does not change, only the aggregation path.
Security: wrapping user input into error strings can leak internals; map to safe client messages at the outermost layer.
err is a bug, and linters flag unchecked errors.if err != nil means Go error handling is weak - Verbosity buys local reasoning; wrapping and errors.Is/As provide structured inspection without a parallel exception hierarchy.error so CLI tools, servers, and tests can recover or report cleanly.err == io.EOF always works - Wrapped errors fail direct equality; use errors.Is(err, io.EOF).%w - Use %v when the inner cause should not be visible to Is/As (security or abstraction boundaries).A single-method interface (Error() string). Concrete types implement it to represent failure without a class hierarchy.
They wanted failure to be visible in function signatures and handled locally, avoiding hidden control flow and distant catch blocks that obscure data flow in large codebases.
Only when the operation succeeded. Never return a typed nil pointer as an error interface value - use var err error or return an untyped nil.
Superficially, but idiomatic Go limits panic to unrecoverable programmer errors. Expected failures use the error return.
fmt.Errorf("context: %w", err) adds a message and preserves the chain for errors.Is and errors.As. Callers can match sentinels or types through wraps.
Export a small stable set. Prefer documented sentinels or types for contract errors; keep internal details unexported or opaque.
Often once at the boundary (HTTP handler, worker loop) after wrapping upstream. Logging at every layer duplicates noise unless each layer adds distinct context.
context.Context returns context.Canceled or context.DeadlineExceeded. Propagate with %w so callers distinguish timeout from I/O failure.
Avoid it. Use errors.Is for expected cases (e.g. io.EOF), not for routine branching across many layers.
errors.Join (Go 1.20+) aggregates multiple errors into one value that unwraps to each constituent. Useful when several validations fail in parallel.
Allocating error values has small cost. Hot paths sometimes use sentinel returns without formatting, but clarity usually outweighs micro-optimizations unless profiles prove otherwise.
Domain layers return error; transport layers map them to status codes and stable JSON bodies. See the API error design article for concrete patterns.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 16, 2026