Error Handling Best Practices
Consistent error strategy across libraries vs applications.
Go teams ship faster when every package agrees on how errors are created, wrapped, inspected, logged, and exposed to clients.
These rules turn the error-handling articles in this section into reviewable habits for libraries, services, and CLIs.
How to Use This List
- Apply during API design, handler implementation, and code review for any function returning
error. - Libraries and applications have different jobs: return vs translate.
- Revisit when onboarding or when a service adds gRPC alongside HTTP.
A - Core Discipline
- Check every
errorreturn immediately. Ignored errors are bugs; use linters (errcheck) in CI. - Return
(zero, err)on failure; never use the success value whenerr != nil. Callers depend on this invariant. - Use
errors.Isanderrors.Asinstead of==and string matching. Survives wrap chains and wording changes. - Wrap with
fmt.Errorf("operation: %w", err)to add context. One wrap per layer with meaningful operation names. - Return
nilerror on success, not typed nil pointers. Preventserr != nilsurprises on success paths.
B - Library vs Application
- Libraries return errors; they do not
logand return the same error. Avoid duplicate log noise unless adding unique context unavailable upstream. - Applications map errors to HTTP/gRPC status at handlers or interceptors. Domain packages stay transport-agnostic.
- Export only stable sentinel and type contracts. Unexported errors free you to refactor internals.
- Do not panic for expected validation or I/O failures in libraries. Return
errorso callers control exit behavior. - Use
%vinstead of%wat public boundaries when inner causes must stay hidden. Security and abstraction trade-off.
C - API and Client Contracts
- Expose stable machine-readable codes (problem
type, gRPC code), not rawError()text. Clients branch on codes. - Map
context.DeadlineExceededto timeout responses (504/408, gRPC deadline exceeded). Distinguish timeouts from not-found. - Log 5xx causes with request ID; return opaque messages to clients. Include stacks only server-side.
- Document which errors are retryable. Optional
Retryable() boolor documented gRPC code policy. - Keep validation errors aggregated with
errors.Joinwhen all fields matter. Single response listing every failure.
D - Concurrency and Operations
- Never write shared
errvariables from goroutines without synchronization. Use channels orerrgroup. - Cancel sibling work on first failure with
errgroup.WithContext. Saves quotas and CPU. - Propagate
context.Contextand wrapctx.Err()with%w. Callers distinguish cancel vs I/O failure. - Capture
debug.Stackin application diagnostics, not in reusable libraries. Stacks are for operators. - Test error paths:
errors.Is,errors.As, and mapped HTTP/gRPC status. Success-path-only tests miss regressions.
E - Style and Maintenance
- Name sentinels
ErrXin PascalCase. Matches standard library conventions. - Prefer few sentinels; use custom types when fields are required. Avoid exception-style hierarchies.
- Keep
Error()strings concise and stable in meaning. Program logic uses fields andIs/As, not substrings. - Review new external dependencies for error wrapping behavior. ORMs and SDKs may need adapter mappers.
- Align team ADR on 400 vs 422 for validation and stick to it. Mixed policies break client SDKs.
FAQs
Should every layer log errors?
No. Log at boundaries or when adding unique diagnostic context. Otherwise return wrapped errors upstream.
When is wrapping unnecessary?
When the error already contains sufficient context for the next caller, or when using %v to intentionally hide the chain.
How many exported errors should a package have?
As few as possible for the documented contract. Prefer extending codes on a type over adding many sentinels.
Should CLI main functions exit with codes?
Yes. Map errors.Is outcomes to exit 1/2/etc. and print user-friendly messages to stderr.
Are string comparisons ever acceptable?
Rarely for user-facing CLI output. Never for library logic; use Is/As.
How do best practices differ for internal monorepos?
Shared domain packages stay transport-agnostic; each binary (API, worker, CLI) owns mapping and logging policy.
Should I use github.com/pkg/errors?
Prefer stdlib %w, errors.Is, and errors.As in new code. Legacy stacks may wrap gradually.
What linter rules help?
Enable errcheck, errorlint, and wrapcheck (team policy) in golangci-lint for consistent wrap and compare style.
How do I document errors for API consumers?
OpenAPI/gRPC docs listing stable codes and statuses; never document internal driver strings.
When should errors.Join replace errgroup?
When you need all failures collected, not fail-fast. Validation and batch linting are common cases.
Should tests assert exact error strings?
Assert errors.Is/As and fields. String equality breaks when messages gain context wraps.
How often should teams revisit error ADRs?
When adding transports (gRPC, GraphQL), changing auth, or after incidents caused by ambiguous status mapping.
Related
- Errors as Values: Go's Error Philosophy - conceptual foundation
- Error Handling Basics - runnable section intro
- Sentinel Errors & errors.Is - sentinel rules in depth
- Error Wrapping with %w & errors.As - wrap mechanics
- API Error Design for HTTP and gRPC Services - client 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).