Errors as Values: Go's Error Philosophy
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.
Summary
- In Go, failure is data - an
errorinterface value returned like any other result - not a control-flow mechanism that unwinds the stack. - Insight: Explicit error returns make failure paths visible in source, keep library APIs small, and let teams build consistent observability without hidden
catchblocks. - Key Concepts:
errorinterface, sentinel errors, wrapping with%w,errors.Is/errors.As, panic vs error, fail-fast at boundaries. - When to Use: Any I/O, parsing, validation, RPC, or state transition where failure is routine and must be handled or reported deliberately.
- Limitations/Trade-offs: Repetitive
if err != nilchecks add verbosity; deep call stacks need disciplined wrapping; there is no built-in retry or classification unless you design it. - Related Topics: Sentinel comparison, wrapping chains, custom error types, concurrent propagation, and API status mapping.
Foundations
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.
Mechanics & Interactions
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.
Advanced Considerations & Applications
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.
Common Misconceptions
- "Go has no exceptions, so errors are optional" - The opposite: every failure path is explicit; ignoring
erris a bug, and linters flag unchecked errors. if err != nilmeans Go error handling is weak - Verbosity buys local reasoning; wrapping anderrors.Is/Asprovide structured inspection without a parallel exception hierarchy.- Libraries should panic on bad input - Panic breaks caller contracts; return
errorso CLI tools, servers, and tests can recover or report cleanly. - Comparing
err == io.EOFalways works - Wrapped errors fail direct equality; useerrors.Is(err, io.EOF). - You must always wrap with
%w- Use%vwhen the inner cause should not be visible toIs/As(security or abstraction boundaries).
FAQs
What is the error interface in Go?
A single-method interface (Error() string). Concrete types implement it to represent failure without a class hierarchy.
Why did Go designers reject exceptions?
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.
When should I return nil for the error value?
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.
Is panic the same as throw?
Superficially, but idiomatic Go limits panic to unrecoverable programmer errors. Expected failures use the error return.
How does wrapping change error handling?
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.
Should libraries export many error variables?
Export a small stable set. Prefer documented sentinels or types for contract errors; keep internal details unexported or opaque.
Where should I log errors?
Often once at the boundary (HTTP handler, worker loop) after wrapping upstream. Logging at every layer duplicates noise unless each layer adds distinct context.
How do errors interact with context cancellation?
context.Context returns context.Canceled or context.DeadlineExceeded. Propagate with %w so callers distinguish timeout from I/O failure.
Can I use errors for flow control like exceptions?
Avoid it. Use errors.Is for expected cases (e.g. io.EOF), not for routine branching across many layers.
What is the difference between errors and multi-error patterns?
errors.Join (Go 1.20+) aggregates multiple errors into one value that unwraps to each constituent. Useful when several validations fail in parallel.
Do errors affect performance?
Allocating error values has small cost. Hot paths sometimes use sentinel returns without formatting, but clarity usually outweighs micro-optimizations unless profiles prove otherwise.
How does this philosophy apply to HTTP APIs?
Domain layers return error; transport layers map them to status codes and stable JSON bodies. See the API error design article for concrete patterns.
Related
- Error Handling Basics - runnable intro examples across the section
- Sentinel Errors & errors.Is - reliable sentinel comparison
- Error Wrapping with %w & errors.As - chains and typed inspection
- Error Handling Best Practices - team-wide conventions for libraries vs apps
- API Error Design for HTTP and gRPC Services - client-facing contracts
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).