Error Handling Basics (Idiomatic Go)
Error Handling Is an Operational Concern
In Go, errors are values. This simplicity is powerful, but in production systems, careless error handling leads to noisy logs, hidden root causes, and unreliable retry behavior.
Error design affects:
- Incident debugging speed
- Retry correctness
- Alert accuracy
- User-facing error clarity
- System resilience
Real Production Failure: The Logged-But-Lost Root Cause
A service returned generic “internal error” messages while logging the real error deep inside a repository layer. The error was logged multiple times at different layers. During an outage, engineers saw thousands of duplicate logs without context of which request triggered which failure.
The actual root cause (database connection pool exhaustion) was hidden inside nested error strings.
Lesson: error wrapping and logging boundaries must be intentional.
Core Principle: Return, Wrap, Log Once
Errors should flow upward with context added at meaningful boundaries.
Basic Wrapping
if err != nil {
return fmt.Errorf("fetch user %d: %w", userID, err)
}
The %w verb preserves the original error for unwrapping.
Sentinel Errors vs Typed Errors
Sentinel Error Example
var ErrNotFound = errors.New("not found")
Use sentinel errors sparingly. They are useful for common, stable conditions.
Typed Error Example
type ValidationError struct {
Field string
Msg string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
Typed errors are preferable when you need structured inspection.
Using errors.Is and errors.As
errors.Is
if errors.Is(err, ErrNotFound) {
// handle not found
}
errors.As
var vErr ValidationError
if errors.As(err, &vErr) {
// handle validation error
}
Never compare error strings.
Failure Classification in Production
Not all errors are equal. Classify failures:
- Permanent (validation errors)
- Transient (network timeout)
- Resource exhaustion (connection pool full)
- Dependency outage
- Programming bugs (nil pointer)
This classification drives retry behavior and alerting.
Retry Safety Foundations
Only retry transient errors.
if errors.Is(err, context.DeadlineExceeded) {
// retry
}
Never blindly retry validation or logical errors.
Context Cancellation Awareness
Always propagate context errors properly.
if errors.Is(err, context.Canceled) {
return err
}
Swallowing context cancellation causes stuck goroutines.
Logging Boundaries
Rule: log at the boundary, not everywhere.
- Repository layer: return wrapped error
- Service layer: add context, return
- Transport layer (HTTP handler): log once with request metadata
Correct Logging Example
if err != nil {
logger.Error("request failed",
"path", r.URL.Path,
"err", err,
)
http.Error(w, "internal error", 500)
}
Avoid logging inside deep layers unless absolutely necessary.
Anti-Patterns
- Comparing error strings
- Wrapping without %w
- Logging the same error at multiple layers
- Returning generic errors without context
- Retrying every failure blindly
Error Taxonomy Strategy
Define clear error categories in larger systems:
- Domain errors
- Infrastructure errors
- Transport errors
This enables cleaner metrics and alert grouping.
Debugging Deeply Wrapped Errors
fmt.Printf("%+v
", err)
Or inspect with errors.Unwrap manually.
Testing Error Paths
Production reliability requires testing failure paths explicitly.
func TestService_NotFound(t *testing.T) {
_, err := svc.GetUser(999)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound")
}
}
Operational Checklist
- All wrapped errors use %w
- errors.Is / errors.As used correctly
- Errors classified for retry logic
- Context errors propagated
- Logged once at boundary
- No string comparison for errors
Final Perspective
Error handling in Go is simple syntactically but complex architecturally. Your error strategy determines how quickly incidents are understood and resolved. Design errors as part of your observability system — not as afterthoughts.