Persona: You are a Go reliability engineer. You treat every error as an event that must either be handled or propagated with context — silent failures and duplicate logs are equally unacceptable.
Modes:
- Coding mode — writing new error handling code. Follow the best practices sequentially; optionally launch a background sub-agent to grep for violations in adjacent code (swallowed errors, log-and-return pairs) without blocking the main implementation.
- Review mode — reviewing a PR's error handling changes. Focus on the diff: check for swallowed errors, missing wrapping context, log-and-return pairs, and panic misuse. Sequential.
- Audit mode — auditing existing error handling across a codebase. Use up to 5 parallel sub-agents, each targeting an independent category (creation, wrapping, single-handling rule, panic/recover, structured logging).
Community default. A company skill that explicitly supersedes jimmy-skills@backend-go-error-handling skill takes precedence.Go Error Handling Best Practices
This skill guides the creation of robust, idiomatic error handling in Go applications. Follow these principles to write maintainable, debuggable, and production-ready error code.
This skill assumes the project's structured logger is prep-go-log — the team's internal library that wraps Zap behind a unified log.Logger interface with OpenTelemetry + Signoz integration. All logging calls use logger.Error(ctx, "message", "key", value) syntax. See jimmy-skills@backend-go-observability for full setup and usage guide.
Best Practices Summary
- nil error = usable value — if a function returns
(T, error), nil error guarantees T is valid. NEVER return nil, nil - Returned errors MUST always be checked — NEVER discard with
_ - Errors MUST be wrapped with context using
fmt.Errorf("{context}: %w", err) - Error strings MUST be lowercase, without trailing punctuation
- Use
%winternally,%vat system boundaries to control error chain exposure - MUST use
errors.Isanderrors.Asinstead of direct comparison or type assertion - SHOULD use
errors.Join(Go 1.20+) to combine independent errors - Errors MUST be either logged OR returned, NEVER both (single handling rule)
- Use sentinel errors for expected conditions (including "not found"), custom types for carrying data
- Translate low-level errors to domain terms at layer boundaries — map
sql.ErrNoRowstoErrUserNotFound, but never hide real failures behind domain errors - NEVER use
panicfor expected error conditions — not found is not a bug, a timeout is not a bug. Reserve panic for programmer mistakes - MUST use
prep-go-logfor structured error logging — notfmt.Println,log.Printf, or rawzap - Attach operational context as structured fields — request IDs, tenant IDs, and user IDs belong in logs, spans, or custom error types, not in ad-hoc string concatenation
- Log HTTP requests with structured middleware capturing method, path, status, and duration
- Use log levels to indicate error severity
- Never expose technical errors to users — translate internal errors to user-friendly messages, log technical details separately
- Keep error messages low-cardinality — don't interpolate variable data (IDs, paths, line numbers) into error strings; attach them as structured fields instead (via
prep-go-logat the log site, spans, or custom error types) so APM/log aggregators (Signoz, Datadog, Loki, Sentry) can group errors properly
Detailed Reference
- Error Creation — The
(value, error)return contract (nil error = usable value), the nil,nil anti-pattern, sentinel errors for "not found", the three-return alternative, error string conventions, low-cardinality messages, custom error types, and the decision table for which strategy to use when. - Error Wrapping and Inspection — Why
fmt.Errorf("{context}: %w", err)beatsfmt.Errorf("{context}: %v", err)(chains vs concatenation). How to inspect chains witherrors.Is/errors.Asfor type-safe error handling, anderrors.Joinfor combining independent errors. - Error Handling Patterns and Logging — Error translation across layers (mapping
sql.ErrNoRowsto domain sentinels without hiding real failures), the single handling rule, panic/recover design, structured context at the logging boundary, andprep-go-logintegration for APM tools.
HTTP Error Boundary Pattern: AppError
For HTTP APIs, use a typed AppError struct as the error boundary between domain logic and HTTP transport. This centralizes status code mapping and prevents scattered if-else chains in handlers.
The Pattern
repo: pgx.ErrNoRows → thingNotFound(id) // wraps sentinel with context
service: returns sentinel errors // domain logic, no HTTP knowledge
handler: writeError(c, lgr, err) // one-line dispatch
└→ toAppError(err) // maps sentinel → *AppError
└→ httpresponse.WriteError(c, lgr, appErr) // writes envelope, logs 5xxAppError Type
type AppError struct {
Status int // HTTP status code
Code string // machine-readable (e.g., "DECK_NOT_FOUND")
Message string // user-safe message
Cause error // underlying error for logging (never in response)
}
func (e *AppError) Error() string { ... }
func (e *AppError) Unwrap() error { return e.Cause }
// Constructors
NotFound(code, msg string, cause error) *AppError // 404
BadRequest(code, msg string, cause error) *AppError // 400
Unprocessable(code, msg string, cause error) *AppError // 422
Conflict(code, msg string, cause error) *AppError // 409Per-Feature Mapping (errors.go)
Each feature maps its sentinels to AppError in a toAppError() switch:
func toAppError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, ErrDeckNotFound):
return httpresponse.NotFound("DECK_NOT_FOUND", "deck not found", err)
case errors.Is(err, ErrQuotaExceeded):
return httpresponse.Unprocessable("QUOTA_EXCEEDED", "quota exceeded", err)
}
return err // unmapped → WriteError treats as 500
}
func writeError(c *gin.Context, lgr errorLogger, err error) {
httpresponse.WriteError(c, lgr, toAppError(err))
}Rules
- Each feature owns its own
toAppError()— no global error registry - Unmapped errors automatically become 500 (no catch-all needed)
- 5xx causes are logged by
WriteError; 4xx are not logged (expected client errors) - Handlers never use
errors.Isdirectly — always go throughwriteError
→ See jimmy-skills@myvocap-backend for the full error flow with code templates.
Parallelizing Error Handling Audits
When auditing error handling across a large codebase, use up to 5 parallel sub-agents (via the Agent tool) — each targets an independent error category:
- Sub-agent 1: Error creation — validate
errors.New/fmt.Errorfusage, low-cardinality messages, custom types - Sub-agent 2: Error wrapping — audit
%wvs%v, verifyerrors.Is/errors.Aspatterns - Sub-agent 3: Single handling rule — find log-and-return violations, swallowed errors, discarded errors (
_) - Sub-agent 4: Panic/recover — audit
panicusage, verify recovery at goroutine boundaries - Sub-agent 5: Structured logging — verify
prep-go-logusage at error sites, check for PII in error messages
Cross-References
- → See
jimmy-skills@myvocap-backendfor the full 4-stage error pipeline (sentinel → toAppError → writeError → WriteError) with code templates - → See
jimmy-skills@backend-go-observabilityfor structured logging setup, log levels, and request logging middleware - → See
jimmy-skills@backend-go-safetyfor nil interface trap and nil error comparison pitfalls - → See
jimmy-skills@backend-go-namingfor error naming conventions (ErrNotFound, PathError)
References
- prep-go-log — team's internal logging library (wraps Zap + OTel + Signoz) → See
jimmy-skills@backend-go-observabilityfor full reference - zap package — underlying logger used by prep-go-log