go-error-handling
Go Error Handling
Go's explicit error handling is a feature, not a limitation. These patterns ensure errors are informative, actionable, and properly propagated.
1. Error Decision Tree
When creating or returning an error, follow this tree:
- Simple, no extra context needed? →
errors.New("message") - Need to add context to existing error? →
fmt.Errorf("doing X: %w", err) - Caller needs to detect this error? → Sentinel
varor custom type - Error carries structured data? → Custom type implementing
error - Propagating from downstream? → Wrap with
%wand add context
2. Sentinel Errors
Use package-level var for errors that callers need to check:
// ✅ Good — exported sentinel error
var (
ErrNotFound = errors.New("user: not found")
ErrUnauthorized = errors.New("user: unauthorized")
)
// Naming convention: Err + Description
// Prefix with package context in the message
Callers check with errors.Is:
if errors.Is(err, user.ErrNotFound) {
// handle not found
}
NEVER compare errors with ==. Always use errors.Is().
3. Custom Error Types
When errors need to carry structured information:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: field %s: %s", e.Field, e.Message)
}
// Callers extract with errors.As:
var valErr *ValidationError
if errors.As(err, &valErr) {
log.Printf("invalid field: %s", valErr.Field)
}
4. Error Wrapping
ALWAYS add context when propagating errors up the stack.
Use %w to preserve the error chain:
// ✅ Good — context added, chain preserved
func getUser(id int64) (*User, error) {
row, err := db.QueryRow(ctx, query, id)
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
// ...
}
// ❌ Bad — no context
return nil, err
// ❌ Bad — chain broken, callers can't errors.Is/As
return nil, fmt.Errorf("failed: %v", err)
When NOT to use %w
Use %v instead of %w when you explicitly want to break the error chain,
preventing callers from depending on internal implementation errors:
// Intentionally hiding internal DB error from public API
return nil, fmt.Errorf("user lookup failed: %v", err)
5. Handle Errors Exactly Once
An error should be either logged OR returned, never both:
// ✅ Good — return the error, let caller decide
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load config %s: %w", path, err)
}
// ...
}
// ❌ Bad — log AND return (error handled twice)
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
log.Printf("failed to read config: %v", err) // handled once
return nil, err // handled again
}
// ...
}
The rule: the component that decides what to do about the error is the one that logs/metrics it. Everyone else wraps and returns.
6. Error Naming Conventions
// Sentinel errors: Err prefix
var ErrNotFound = errors.New("not found")
// Error types: Error suffix
type NotFoundError struct { ... }
type ValidationError struct { ... }
// Error messages: lowercase, no punctuation, no "failed to" prefix
// Include context: "package: action: detail"
errors.New("auth: token expired")
fmt.Errorf("user: get by id %d: %w", id, err)
7. Panic Rules
Panic is NOT error handling. Use panic only when:
- Program initialization fails and cannot continue (
template.Must, flag parsing) - Programmer error that should never happen (violated invariant)
- Nil dereference that indicates a bug, not a runtime condition
In tests, use t.Fatal / t.FailNow, never panic.
In HTTP handlers and middleware, recover from panics at the boundary to prevent one request from crashing the server.
8. Error Checking Patterns
// Inline error check — preferred for simple cases
if err := doSomething(); err != nil {
return fmt.Errorf("do something: %w", err)
}
// Multi-return with named result — acceptable for complex functions
func process() (result string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("process: %w", err)
}
}()
// ...
}
// errors.Join for multiple errors (Go 1.20+)
var errs []error
for _, item := range items {
if err := validate(item); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
Verification Checklist
- No
_discarding errors (unless explicitly justified with comment) - Every
fmt.Errorfwrapping uses%w(or%vwith documented reason) - Sentinel errors use
var Err...naming - Custom error types implement
errorinterface - Callers use
errors.Is/errors.As, never==or type assertion - No log-and-return patterns
- Error messages are lowercase, contextual, chain-friendly