Error Handling Patterns in Go
Production-ready error handling patterns in Go — wrapping, sentinel errors, custom error types, error groups, and structured logging for backend services.
// table of contents (10 sections)
Error handling in Go is intentionally explicit. No exceptions, no try-catch — just errors as values. This simplicity is powerful, but it requires discipline to avoid repetitive error handling code and to provide useful context when things go wrong.
After years building production Go services, I have settled on patterns that keep error handling clean, informative, and maintainable. This post covers those patterns: when to wrap, when to create custom types, and how to structure errors that help you debug production issues.
The Golden Rule: Always Handle Errors
Go does not force you to handle errors, but you always should:
// BAD: Ignoring errors
file, _ := os.Open("config.json")
json.NewDecoder(file).Decode(&config)
// GOOD: Handling every error
file, err := os.Open("config.json")
if err != nil {
return fmt.Errorf("failed to open config: %w", err)
}
if err := json.NewDecoder(file).Decode(&config); err != nil {
return fmt.Errorf("failed to decode config: %w", err)
}
The only time you should intentionally ignore an error is when you have documented why:
// Close errors from ioutil.ReadAll are usually not worth handling
// because we already have the data we need
data, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()
Sentinel Errors
Sentinel errors are predefined errors that callers can check for specific behavior:
// Package-level sentinel errors
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidPassword = errors.New("invalid password")
ErrEmailTaken = errors.New("email already taken")
)
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE email = $1", email)
var user User
if err := row.Scan(&user.ID, &user.Email, &user.Name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("failed to query user: %w", err)
}
return &user, nil
}
// Caller can check for specific error
user, err := repo.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}
When to use sentinel errors:
- Public APIs where callers need to distinguish specific error cases
- Errors that trigger different handling logic
- Errors that should not be wrapped (they are the terminal error)
When to avoid:
- Errors that need additional context (line numbers, request IDs)
- Errors that occur in many places with different meanings
Error Wrapping with Context
Since Go 1.13, fmt.Errorf with %w wraps errors while preserving the original:
func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
order, err := s.repo.GetOrder(ctx, orderID)
if err != nil {
// Wrap with context about what we were trying to do
return fmt.Errorf("failed to get order %s: %w", orderID, err)
}
if err := s.payment.Charge(ctx, order.Total); err != nil {
return fmt.Errorf("failed to charge payment for order %s: %w", orderID, err)
}
return nil
}
The key insight: wrap at layer boundaries. Each layer adds its own context:
// HTTP handler layer
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
if err := h.service.ProcessOrder(r.Context(), orderID); err != nil {
h.Error(w, fmt.Errorf("create order failed: %w", err))
return
}
}
// Service layer
func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
order, err := s.repo.GetOrder(ctx, orderID)
if err != nil {
return fmt.Errorf("get order: %w", err)
}
// ...
}
// Repository layer
func (r *Repository) GetOrder(ctx context.Context, id string) (*Order, error) {
row := r.db.QueryRowContext(ctx, "SELECT * FROM orders WHERE id = $1", id)
// ...
if err := row.Scan(...); err != nil {
return nil, fmt.Errorf("scan order: %w", err)
}
}
When you read the error stack, you get the full story:
create order failed: get order: scan order: sql: Scan error on column index 2
Custom Error Types
Sometimes you need more than a string — attach structured data to your errors:
// ValidationError holds field-level validation errors
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %s: %s", e.Field, e.Message)
}
// APIError is the base type for all API errors
type APIError struct {
Code string
Message string
Status int
Err error
}
func (e *APIError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *APIError) Unwrap() error {
return e.Err
}
// HTTP returns the HTTP status code
func (e *APIError) HTTP() int {
if e.Status > 0 {
return e.Status
}
return http.StatusInternalServerError
}
// Usage
func (s *Service) CreateUser(ctx context.Context, req *CreateUserRequest) error {
if !isValidEmail(req.Email) {
return &ValidationError{
Field: "email",
Message: "must be a valid email address",
}
}
if err := s.repo.CreateUser(ctx, req); err != nil {
return &APIError{
Code: "USER_CREATE_FAILED",
Message: "failed to create user",
Status: http.StatusInternalServerError,
Err: err,
}
}
return nil
}
Check for custom errors with type assertions:
user, err := s.CreateUser(ctx, req)
if err != nil {
var ve *ValidationError
if errors.As(err, &ve) {
return ValidationErrorResponse{Field: ve.Field, Message: ve.Message}
}
var apiErr *APIError
if errors.As(err, &apiErr) {
return ErrorResponse{Code: apiErr.Code, Message: apiErr.Message}
}
return ErrorResponse{Code: "INTERNAL_ERROR", Message: "something went wrong"}
}
Error Groups for Concurrent Operations
When running multiple operations concurrently, use errgroup to collect errors:
import "golang.org/x/sync/errgroup"
func (s *Service) FetchUserData(ctx context.Context, userID string) (*UserData, error) {
g, ctx := errgroup.WithContext(ctx)
var user *User
var orders []Order
var payments []Payment
// Fetch user concurrently with orders and payments
g.Go(func() error {
var err error
user, err = s.repo.GetUser(ctx, userID)
return err
})
g.Go(func() error {
var err error
orders, err = s.repo.GetOrders(ctx, userID)
return err
})
g.Go(func() error {
var err error
payments, err = s.repo.GetPayments(ctx, userID)
return err
})
// Wait for all goroutines
if err := g.Wait(); err != nil {
return nil, fmt.Errorf("failed to fetch user data: %w", err)
}
return &UserData{
User: user,
Orders: orders,
Payments: payments,
}, nil
}
For collecting multiple errors without early exit:
type MultiError []error
func (m MultiError) Error() string {
var sb strings.Builder
sb.WriteString("multiple errors occurred:")
for i, err := range m {
sb.WriteString(fmt.Sprintf("\n%d. %s", i+1, err.Error()))
}
return sb.String()
}
func (m MultiError) HasErrors() bool {
return len(m) > 0
}
func (s *Service) ValidateBatch(ctx context.Context, items []Item) MultiError {
var errs MultiError
for _, item := range items {
if err := s.ValidateItem(ctx, item); err != nil {
errs = append(errs, fmt.Errorf("item %s: %w", item.ID, err))
}
}
return errs
}
Structured Logging With Errors
Errors should be logged with context. Use structured logging:
import "log/slog"
func (s *Service) ProcessPayment(ctx context.Context, payment *Payment) error {
if err := s.gateway.Charge(ctx, payment); err != nil {
// Log error with structured context
slog.ErrorContext(ctx, "payment gateway charge failed",
"payment_id", payment.ID,
"amount", payment.Amount,
"currency", payment.Currency,
"user_id", payment.UserID,
"error", err,
)
return fmt.Errorf("charge payment %s: %w", payment.ID, err)
}
slog.InfoContext(ctx, "payment charged successfully",
"payment_id", payment.ID,
"amount", payment.Amount,
)
return nil
}
For logs that might be queried or analyzed, include error codes:
type ErrorCode string
const (
ErrCodePaymentDeclined ErrorCode = "PAYMENT_DECLINED"
ErrCodePaymentTimeout ErrorCode = "PAYMENT_TIMEOUT"
ErrCodeInvalidRequest ErrorCode = "INVALID_REQUEST"
ErrCodeInternalError ErrorCode = "INTERNAL_ERROR"
)
type CodedError struct {
Code ErrorCode
Err error
}
func (e *CodedError) Error() string {
return fmt.Sprintf("[%s] %v", e.Code, e.Err)
}
func (e *CodedError) Unwrap() error {
return e.Err
}
// Usage
func (s *Service) ProcessPayment(ctx context.Context, payment *Payment) error {
if err := s.gateway.Charge(ctx, payment); err != nil {
return &CodedError{
Code: ErrCodePaymentDeclined,
Err: err,
}
}
return nil
}
Error Handling for External Services
External service calls need special care — timeouts, retries, and graceful degradation:
type ExternalServiceClient struct {
client *http.Client
baseURL string
logger *slog.Logger
}
func NewExternalServiceClient(baseURL string) *ExternalServiceClient {
return &ExternalServiceClient{
client: &http.Client{
Timeout: 10 * time.Second,
},
baseURL: baseURL,
}
}
func (c *ExternalServiceClient) CallAPI(ctx context.Context, endpoint string, payload []byte) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+endpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "MyService/1.0")
resp, err := c.client.Do(req)
if err != nil {
// Check for timeout
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("external service timeout: %w", err)
}
return nil, fmt.Errorf("external service request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
// Handle non-2xx responses
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("external service returned status %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
Add retry logic for transient failures:
func (c *ExternalServiceClient) CallWithRetry(ctx context.Context, endpoint string, payload []byte, maxRetries int) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff
backoff := time.Duration(1<<uint(attempt)) * time.Second
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
}
body, err := c.CallAPI(ctx, endpoint, payload)
if err == nil {
return body, nil
}
lastErr = err
// Don't retry client errors (4xx)
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Status >= 400 && apiErr.Status < 500 {
return nil, err
}
}
return nil, fmt.Errorf("after %d retries: %w", maxRetries, lastErr)
}
Error Handling in Database Operations
Database errors need translation to domain errors:
func (r *Repository) CreateUser(ctx context.Context, user *User) error {
query := `INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`
_, err := r.db.ExecContext(ctx, query, user.ID, user.Email, user.Name)
if err != nil {
// Check for unique constraint violation
if isDuplicateKeyError(err) {
return &APIError{
Code: "EMAIL_ALREADY_EXISTS",
Message: "a user with this email already exists",
Status: http.StatusConflict,
Err: err,
}
}
return fmt.Errorf("failed to create user: %w", err)
}
return nil
}
// Database-specific error checking
func isDuplicateKeyError(err error) bool {
if err == nil {
return false
}
// PostgreSQL
if strings.Contains(err.Error(), "duplicate key") {
return true
}
// MySQL
if strings.Contains(err.Error(), "Duplicate entry") {
return true
}
// SQLite
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
return true
}
return false
}
Testing Error Paths
Test both the error and non-error paths:
func TestCreateUser_DuplicateEmail(t *testing.T) {
db := setupTestDB(t)
repo := NewRepository(db)
// Create first user
user1 := &User{ID: "1", Email: "test@example.com", Name: "Test"}
if err := repo.CreateUser(context.Background(), user1); err != nil {
t.Fatalf("first create should succeed: %v", err)
}
// Try to create duplicate
user2 := &User{ID: "2", Email: "test@example.com", Name: "Test2"}
err := repo.CreateUser(context.Background(), user2)
if err == nil {
t.Fatal("expected error for duplicate email, got nil")
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T", err)
}
if apiErr.Code != "EMAIL_ALREADY_EXISTS" {
t.Errorf("expected code EMAIL_ALREADY_EXISTS, got %s", apiErr.Code)
}
if apiErr.Status != http.StatusConflict {
t.Errorf("expected status 409, got %d", apiErr.Status)
}
}
Key Takeaways
- Always handle errors — never ignore them without documentation
- Use sentinel errors for public APIs — let callers distinguish cases
- Wrap errors at layer boundaries — add context about what was being attempted
- Create custom error types for structured data — validation, API errors
- Use
errgroupfor concurrent operations — collect and handle multiple errors - Log errors with structured context — include IDs, amounts, and relevant metadata
- Translate database and external errors — map to domain errors
- Test error paths — verify error types, codes, and messages
Good error handling is about making failures debuggable. When production fails at 3 AM, you want errors that tell you what went wrong, where it happened, and what context led to the failure. The patterns in this post will help you build Go services that fail informatively.
You might also like
Go Context: Beyond Cancellation
Understanding context.Context in Go — timeouts, deadlines, request-scoped values, and cancellation propagation patterns for production backend services.
Building RESTful APIs with Go and Chi
A practical guide to building production RESTful APIs with Go and Chi router — routing, middleware, request validation, error handling, and clean architecture.
Go Concurrency: Goroutines and Channels in Practice
Practical goroutines and channels patterns for production Go services — worker pools, pipelines, fan-in/fan-out, rate limiting, and common concurrency pitfalls.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Enjoyed This Post?
Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.
