Go Error Handling Done Right: Patterns for Production Code
Backend Developer | Golang & Python I enjoy building reliable APIs, distributed systems, and automation tools. Writing here about backend engineering, system design, and real-world dev experiences.
Introduction
I'll be honest—when I first saw Go's error handling, I hated it.
result, err := doSomething()
if err != nil {
return err
}
"Are you kidding me? I have to check errors after EVERY function call?"
Coming from languages with try-catch blocks, this felt primitive. But after shipping production Go code for years, I get it now. Go's error handling is explicit, predictable, and forces you to think about failure cases.
In this post, I'll show you Go error patterns that actually work in production. Not academic examples—real code that handles errors properly.
The Basics: Errors Are Values
In Go, errors are just values:
type error interface {
Error() string
}
Any type with an Error() string method is an error. That's it.
result, err := os.Open("file.txt")
if err != nil {
log.Fatal(err)
}
Simple. Explicit. No hidden control flow.
Creating Errors
import "errors"
// Simple error
err := errors.New("something went wrong")
// Formatted error
err := fmt.Errorf("user %s not found", userID)
Error Wrapping and Unwrapping (Go 1.13+)
This changed everything. Before Go 1.13, we lost context when returning errors.
The Problem
func GetUser(id string) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, err // Lost context!
}
return user, nil
}
When this fails, you get: record not found
But WHERE did it fail? Which function? Which user?
The Solution: Error Wrapping
func GetUser(id string) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, fmt.Errorf("failed to get user %s: %w", id, err)
}
return user, nil
}
Now you get: failed to get user 12345: failed to query database: record not found
Beautiful. Clear error chain.
%w vs %v
// %w wraps the error (preserves error chain)
fmt.Errorf("context: %w", err)
// %v converts to string (breaks error chain)
fmt.Errorf("context: %v", err)
Always use %w unless you explicitly want to hide the original error.
Unwrapping Errors
import "errors"
originalErr := errors.New("connection failed")
wrappedErr := fmt.Errorf("database error: %w", originalErr)
// Unwrap to get original error
err := errors.Unwrap(wrappedErr)
fmt.Println(err) // "connection failed"
// Check if error is specific type
if errors.Is(wrappedErr, originalErr) {
fmt.Println("It's the original error!")
}
Custom Error Types
For complex errors, create custom types:
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)
}
func ValidateUser(user *User) error {
if user.Email == "" {
return &ValidationError{
Field: "email",
Message: "email is required",
}
}
if user.Age < 18 {
return &ValidationError{
Field: "age",
Message: "must be 18 or older",
}
}
return nil
}
Type Assertions with errors.As
err := ValidateUser(user)
if err != nil {
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Printf("Validation failed: %s on %s\n",
validationErr.Message, validationErr.Field)
}
}
Sentinel Errors
Predefined errors for common cases:
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidInput = errors.New("invalid input")
)
func GetOrder(id string) (*Order, error) {
order, err := db.FindOrder(id)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, ErrNotFound
}
return nil, fmt.Errorf("database error: %w", err)
}
return order, nil
}
// Caller can check specific errors
order, err := GetOrder("123")
if errors.Is(err, ErrNotFound) {
return http.StatusNotFound
}
Standard library uses this pattern:
if errors.Is(err, io.EOF) {
// End of file
}
if errors.Is(err, context.DeadlineExceeded) {
// Timeout
}
Real-World Pattern: Multi-Layer Error Handling
Here's how I structure errors in production:
// Domain errors (business logic layer)
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidPassword = errors.New("invalid password")
ErrAccountLocked = errors.New("account locked")
)
// Repository layer
type UserRepository struct {
db *mongo.Database
}
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
var user User
err := r.db.Collection("users").
FindOne(ctx, bson.M{"email": email}).
Decode(&user)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("database query failed: %w", err)
}
return &user, nil
}
// Service layer
type AuthService struct {
repo *UserRepository
}
func (s *AuthService) Login(ctx context.Context, email, password string) (*Session, error) {
user, err := s.repo.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, ErrInvalidPassword // Don't reveal user existence
}
return nil, fmt.Errorf("failed to find user: %w", err)
}
if user.Locked {
return nil, ErrAccountLocked
}
if !user.CheckPassword(password) {
return nil, ErrInvalidPassword
}
session, err := s.createSession(ctx, user)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
return session, nil
}
// HTTP handler layer
func (h *AuthHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.respondError(w, http.StatusBadRequest, "Invalid request")
return
}
session, err := h.authService.Login(r.Context(), req.Email, req.Password)
if err != nil {
switch {
case errors.Is(err, ErrInvalidPassword):
h.respondError(w, http.StatusUnauthorized, "Invalid credentials")
case errors.Is(err, ErrAccountLocked):
h.respondError(w, http.StatusForbidden, "Account locked")
default:
h.logger.Error("login failed", zap.Error(err))
h.respondError(w, http.StatusInternalServerError, "Internal server error")
}
return
}
h.respondJSON(w, http.StatusOK, session)
}
Each layer:
Repository: Converts DB errors to domain errors
Service: Adds business logic context
Handler: Converts to HTTP responses
Handling Multiple Errors
Pattern 1: Return First Error
func ProcessOrder(order *Order) error {
if err := ValidateOrder(order); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
if err := CheckInventory(order); err != nil {
return fmt.Errorf("inventory check failed: %w", err)
}
if err := ChargePayment(order); err != nil {
return fmt.Errorf("payment failed: %w", err)
}
return nil
}
Pattern 2: Collect All Errors
type MultiError struct {
Errors []error
}
func (m *MultiError) Error() string {
var msgs []string
for _, err := range m.Errors {
msgs = append(msgs, err.Error())
}
return strings.Join(msgs, "; ")
}
func (m *MultiError) Add(err error) {
if err != nil {
m.Errors = append(m.Errors, err)
}
}
func (m *MultiError) HasErrors() bool {
return len(m.Errors) > 0
}
func ValidateOrder(order *Order) error {
var errs MultiError
if order.CustomerID == "" {
errs.Add(errors.New("customer ID required"))
}
if order.Amount <= 0 {
errs.Add(errors.New("amount must be positive"))
}
if len(order.Items) == 0 {
errs.Add(errors.New("order must have items"))
}
if errs.HasErrors() {
return &errs
}
return nil
}
Pattern 3: Error Group (golang.org/x/sync/errgroup)
For concurrent operations:
import "golang.org/x/sync/errgroup"
func FetchUserData(ctx context.Context, userID string) (*UserData, error) {
var (
profile *Profile
orders []Order
payments []Payment
)
g, ctx := errgroup.WithContext(ctx)
// Fetch profile
g.Go(func() error {
var err error
profile, err = fetchProfile(ctx, userID)
return err
})
// Fetch orders
g.Go(func() error {
var err error
orders, err = fetchOrders(ctx, userID)
return err
})
// Fetch payments
g.Go(func() error {
var err error
payments, err = fetchPayments(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{
Profile: profile,
Orders: orders,
Payments: payments,
}, nil
}
Panic and Recover
Use panic only for unrecoverable errors:
// Bad: Using panic for normal errors
func GetUser(id string) *User {
user, err := db.FindUser(id)
if err != nil {
panic(err) // NO!
}
return user
}
// Good: Return error
func GetUser(id string) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, err
}
return user, nil
}
When to Panic
Program initialization fails (can't load config, can't connect to required DB)
Programmer errors (nil pointer, impossible state)
Unrecoverable runtime errors
func LoadConfig(path string) *Config {
config, err := parseConfig(path)
if err != nil {
panic(fmt.Sprintf("failed to load config: %v", err))
}
return config
}
Recovering from Panics
Use recover in HTTP handlers to prevent crashes:
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Error("panic recovered",
zap.Any("error", err),
zap.String("url", r.URL.Path),
)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Error Logging Best Practices
Structured Logging with Context
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
logger := s.logger.With(
zap.String("customer_id", req.CustomerID),
zap.String("order_type", req.Type),
)
order, err := s.repo.Create(ctx, req)
if err != nil {
logger.Error("failed to create order",
zap.Error(err),
zap.Duration("elapsed", time.Since(start)),
)
return nil, fmt.Errorf("failed to create order: %w", err)
}
logger.Info("order created successfully",
zap.String("order_id", order.ID),
)
return order, nil
}
Log at the Right Level
if err != nil {
switch {
case errors.Is(err, ErrNotFound):
// Don't log - expected behavior
return nil, err
case errors.Is(err, ErrInvalidInput):
// Warning - client error
logger.Warn("invalid input", zap.Error(err))
return nil, err
default:
// Error - unexpected
logger.Error("operation failed", zap.Error(err))
return nil, err
}
}
Testing Error Handling
func TestOrderService_CreateOrder_ValidationError(t *testing.T) {
service := NewOrderService(mockRepo, logger)
req := CreateOrderRequest{
CustomerID: "", // Invalid
Amount: 100,
}
_, err := service.CreateOrder(context.Background(), req)
if err == nil {
t.Fatal("expected error, got nil")
}
var validationErr *ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T", err)
}
if validationErr.Field != "customer_id" {
t.Errorf("expected field 'customer_id', got '%s'", validationErr.Field)
}
}
func TestOrderService_CreateOrder_DatabaseError(t *testing.T) {
mockRepo := &MockOrderRepository{
CreateError: errors.New("connection failed"),
}
service := NewOrderService(mockRepo, logger)
_, err := service.CreateOrder(context.Background(), validRequest)
if err == nil {
t.Fatal("expected error, got nil")
}
// Error should be wrapped
if !strings.Contains(err.Error(), "connection failed") {
t.Errorf("error should contain original error: %v", err)
}
}
Production Checklist
✅ Always check returned errors ✅ Use %w to wrap errors with context ✅ Define sentinel errors for common cases ✅ Create custom error types for structured errors ✅ Log errors at appropriate levels ✅ Don't log the same error multiple times ✅ Return errors, don't panic (except initialization) ✅ Use errors.Is() and errors.As() for error checking ✅ Recover from panics in HTTP handlers ✅ Test error handling paths
Common Mistakes
Mistake 1: Ignoring Errors
// Bad
result, _ := doSomething()
// Good
result, err := doSomething()
if err != nil {
return err
}
Mistake 2: Double Logging
// Bad: Logged at every level
func Service() error {
err := Repository()
if err != nil {
log.Error(err) // Logged here
return err
}
}
func Handler() {
err := Service()
if err != nil {
log.Error(err) // Logged again!
}
}
// Good: Log once at top level
func Service() error {
err := Repository()
if err != nil {
return fmt.Errorf("service failed: %w", err)
}
}
func Handler() {
err := Service()
if err != nil {
log.Error(err) // Logged once
}
}
Mistake 3: Losing Error Context
// Bad
if err != nil {
return errors.New("failed")
}
// Good
if err != nil {
return fmt.Errorf("failed to process order %s: %w", orderID, err)
}
Conclusion
Go error handling is explicit by design. It forces you to think about failures.
Key principles:
Errors are values
Check every error
Wrap errors with context
Use custom types for structured errors
Log once, at the right level
It's verbose, yes. But you'll never wonder where an error came from or what state your program is in.
Questions? Let me know in the comments!