The Error Handling Debate is Over
For years, people complained about Go's if err != nil pattern. In 2026, the Go team introduced sentinel errors, error wrapping, and the errors.Is/As patterns. The debate is over — these patterns work.
The key insight: Go's error handling forces you to deal with errors immediately, at the call site. That's not a bug — it's the feature that makes Go services remarkably reliable in production.
Pattern 1: Typed Errors with Custom Error Types
Stop using generic errors. Define specific error types for your domain:
type AppError struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"-"`
Err error `json:"-"`
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *AppError) Unwrap() error {
return e.Err
}
// Sentinel errors for your domain
var (
ErrNotFound = &AppError{Code: "NOT_FOUND", Message: "Resource not found", Status: 404}
ErrUnauthorized = &AppError{Code: "UNAUTHORIZED", Message: "Authentication required", Status: 401}
ErrForbidden = &AppError{Code: "FORBIDDEN", Message: "Insufficient permissions", Status: 403}
ErrConflict = &AppError{Code: "CONFLICT", Message: "Resource already exists", Status: 409}
ErrValidation = &AppError{Code: "VALIDATION_ERROR", Message: "Invalid input", Status: 422}
)
func NewNotFoundError(resource string, id interface{}) *AppError {
return &AppError{
Code: "NOT_FOUND",
Message: fmt.Sprintf("%s with ID %v not found", resource, id),
Status: 404,
}
}
Pattern 2: Error Wrapping with Context
Always wrap errors with context. A bare error in production logs is useless:
// BAD: No context
func GetUser(ctx context.Context, db *sqlx.DB, id string) (*User, error) {
var user User
err := db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = $1", id)
return &user, err // What failed? Which query? What ID?
}
// GOOD: Rich context
func GetUser(ctx context.Context, db *sqlx.DB, id string) (*User, error) {
var user User
err := db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = $1", id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, NewNotFoundError("user", id)
}
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return &user, nil
}
Pattern 3: Error Propagation with Error Groups
For concurrent operations, use errgroup to collect errors from multiple goroutines:
import "golang.org/x/sync/errgroup"
func ProcessOrder(ctx context.Context, order Order) error {
g, ctx := errgroup.WithContext(ctx)
// Parallel independent operations
g.Go(func() error {
return updateInventory(ctx, order.Items)
})
g.Go(func() error {
return processPayment(ctx, order.Payment)
})
g.Go(func() error {
return sendNotification(ctx, order.Customer)
})
if err := g.Wait(); err != nil {
return fmt.Errorf("process order %s: %w", order.ID, err)
}
return nil
}
Pattern 4: The Error Handler Chain
In production, you need a centralized error handler. Here's the pattern I use with Fiber:
func ErrorHandler(c *fiber.Ctx, err error) error {
// Application errors
var appErr *AppError
if errors.As(err, &appErr) {
return c.Status(appErr.Status).JSON(fiber.Map{
"error": fiber.Map{
"code": appErr.Code,
"message": appErr.Message,
},
})
}
// Database errors
var pqErr *pq.Error
if errors.As(err, &pqErr) {
log.Error().Err(err).Str("code", pqErr.Code.Name()).Msg("database error")
return c.Status(500).JSON(fiber.Map{
"error": fiber.Map{
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred",
},
})
}
// Unknown errors - log everything
log.Error().Err(err).Msg("unhandled error")
return c.Status(500).JSON(fiber.Map{
"error": fiber.Map{
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred",
},
})
}
Pattern 5: The Retry Pattern
For transient failures (network, database connections), implement retries with exponential backoff:
func WithRetry(ctx context.Context, maxAttempts int, fn func() error) error {
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
if err := fn(); err != nil {
lastErr = err
// Don't retry on permanent errors
var appErr *AppError
if errors.As(err, &appErr) && appErr.Status < 500 {
return err
}
// Exponential backoff
delay := time.Duration(attempt+1) * 100 * time.Millisecond
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
continue
}
}
return nil
}
return fmt.Errorf("after %d attempts: %w", maxAttempts, lastErr)
}
Testing Error Paths
The most overlooked part of Go testing is testing error paths. Here's how to do it right:
func TestGetUser_NotFound(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
_, err := GetUser(context.Background(), db, "nonexistent-id")
if err == nil {
t.Fatal("expected error, got nil")
}
var appErr *AppError
if !errors.As(err, &appErr) {
t.Fatalf("expected AppError, got %T", err)
}
if appErr.Code != "NOT_FOUND" {
t.Errorf("expected code NOT_FOUND, got %s", appErr.Code)
}
if appErr.Status != 404 {
t.Errorf("expected status 404, got %d", appErr.Status)
}
}
The Bottom Line
Good error handling in Go isn't about writing less code. It's about writing the right code at the right place. Typed errors, context wrapping, centralized handling, and proper testing — that's the recipe for production-grade Go services.