Go Error Handling: Patterns from 3 Years of Production Code
I’ve been writing Go professionally for three years now, and error handling is still one of the most debated topics in the community. The if err != nil pattern is verbose, but I’ve come to appreciate its explicitness.
This post covers the error handling patterns I use in production. These aren’t theoretical - they’re battle-tested in microservices handling millions of requests per day.
Table of Contents
The Basics: Don’t Ignore Errors
This seems obvious, but I see it violated constantly:
// Bad
data, _ := ioutil.ReadFile("config.json")
// Good
data, err := ioutil.ReadFile("config.json")
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
The blank identifier _ is tempting when you’re prototyping, but it’s dangerous in production. I’ve debugged too many issues caused by ignored errors.
Our team uses a linter that flags ignored errors. It’s saved us multiple times.
Pattern 1: Wrap Errors with Context
Go 1.13 (released a few months ago) added error wrapping with %w:
func LoadUser(id string) (*User, error) {
data, err := db.Query("SELECT * FROM users WHERE id = ?", id)
if err != nil {
return nil, fmt.Errorf("failed to query user %s: %w", id, err)
}
// ...
}
The %w verb wraps the error, preserving the original error for inspection:
err := LoadUser("123")
if err != nil {
// Check if it's a specific error
if errors.Is(err, sql.ErrNoRows) {
// Handle not found
}
// Or check error type
var netErr *net.OpError
if errors.As(err, &netErr) {
// Handle network error
}
}
Before Go 1.13, we used the github.com/pkg/errors package. The standard library approach is similar but built-in.
Pattern 2: Custom Error Types
For domain-specific errors, I create custom types:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Message)
}
func ValidateUser(user *User) error {
if user.Email == "" {
return &ValidationError{
Field: "email",
Message: "email is required",
}
}
if !strings.Contains(user.Email, "@") {
return &ValidationError{
Field: "email",
Message: "invalid email format",
}
}
return nil
}
Then check for it:
err := ValidateUser(user)
if err != nil {
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// Return 400 Bad Request with field-specific error
return &APIError{
Status: 400,
Message: validationErr.Message,
Field: validationErr.Field,
}
}
// Other errors return 500
return &APIError{Status: 500, Message: "internal error"}
}
This pattern lets me handle different error types differently in HTTP handlers.
Pattern 3: Sentinel Errors
For well-known error conditions, I use sentinel errors:
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidInput = errors.New("invalid input")
)
func GetUser(id string) (*User, error) {
user, err := db.FindUser(id)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("database error: %w", err)
}
return user, nil
}
Check with errors.Is:
user, err := GetUser("123")
if errors.Is(err, ErrNotFound) {
return 404
}
if err != nil {
return 500
}
The advantage: callers can check for specific conditions without parsing error strings.
Pattern 4: Error Aggregation
When validating multiple fields, I collect all errors instead of returning on first failure:
type ErrorList []error
func (e ErrorList) Error() string {
var msgs []string
for _, err := range e {
msgs = append(msgs, err.Error())
}
return strings.Join(msgs, "; ")
}
func ValidateUser(user *User) error {
var errs ErrorList
if user.Email == "" {
errs = append(errs, &ValidationError{
Field: "email",
Message: "required",
})
}
if user.Name == "" {
errs = append(errs, &ValidationError{
Field: "name",
Message: "required",
})
}
if len(user.Password) < 8 {
errs = append(errs, &ValidationError{
Field: "password",
Message: "must be at least 8 characters",
})
}
if len(errs) > 0 {
return errs
}
return nil
}
This gives users all validation errors at once, not one at a time.
Pattern 5: Retry with Exponential Backoff
For transient errors (network issues, rate limits), I retry with backoff:
func FetchWithRetry(url string, maxRetries int) (*Response, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
resp, err := http.Get(url)
if err == nil {
return resp, nil
}
lastErr = err
// Don't retry on client errors
if resp != nil && resp.StatusCode >= 400 && resp.StatusCode < 500 {
return nil, fmt.Errorf("client error: %w", err)
}
// Exponential backoff: 1s, 2s, 4s, 8s...
backoff := time.Duration(1<<uint(i)) * time.Second
time.Sleep(backoff)
}
return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
}
I use this pattern for external API calls. It’s saved us from cascading failures when dependencies have brief outages.
Pattern 6: Panic for Programmer Errors
I use panic only for programmer errors that should never happen:
func MustLoadConfig(path string) *Config {
config, err := LoadConfig(path)
if err != nil {
panic(fmt.Sprintf("failed to load config: %v", err))
}
return config
}
// Call during initialization
func init() {
config = MustLoadConfig("config.json")
}
The Must prefix is a convention that signals “this will panic on error.”
I never use panic for runtime errors that can be handled. Network failures, invalid user input, etc. should always return errors.
Pattern 7: Defer for Cleanup
Use defer to ensure cleanup happens even if errors occur:
func ProcessFile(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close() // Always closes, even if error occurs below
data, err := ioutil.ReadAll(file)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
return process(data)
}
Be careful with defer in loops - it doesn’t execute until function returns:
// Bad: file handles leak until function returns
for _, path := range paths {
file, _ := os.Open(path)
defer file.Close() // Doesn't close until loop finishes!
process(file)
}
// Good: close immediately
for _, path := range paths {
func() {
file, _ := os.Open(path)
defer file.Close() // Closes at end of anonymous function
process(file)
}()
}
Pattern 8: Logging Errors
I log errors at the point where they’re handled, not where they’re created:
// Bad: logs at every level
func LoadUser(id string) (*User, error) {
user, err := db.Query(id)
if err != nil {
log.Printf("database error: %v", err) // Don't log here
return nil, err
}
return user, nil
}
func HandleRequest(w http.ResponseWriter, r *http.Request) {
user, err := LoadUser("123")
if err != nil {
log.Printf("failed to load user: %v", err) // Log here
http.Error(w, "Internal error", 500)
}
}
If you log at every level, you get duplicate log entries for the same error. Log once, at the top level where you handle it.
Pattern 9: Structured Error Context
For complex errors, I include structured context:
type ServiceError struct {
Op string // Operation that failed
Service string // Service name
Err error // Underlying error
Context map[string]interface{} // Additional context
}
func (e *ServiceError) Error() string {
return fmt.Sprintf("%s.%s failed: %v", e.Service, e.Op, e.Err)
}
func (e *ServiceError) Unwrap() error {
return e.Err
}
func FetchUser(id string) (*User, error) {
user, err := api.Get("/users/" + id)
if err != nil {
return nil, &ServiceError{
Op: "FetchUser",
Service: "UserAPI",
Err: err,
Context: map[string]interface{}{
"user_id": id,
"url": "/users/" + id,
},
}
}
return user, nil
}
This makes debugging much easier. When an error occurs, I have all the context I need.
Pattern 10: HTTP Error Responses
For HTTP APIs, I map errors to status codes:
type APIError struct {
Status int
Message string
Details interface{}
}
func (e *APIError) Error() string {
return e.Message
}
func HandleError(w http.ResponseWriter, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
w.WriteHeader(apiErr.Status)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": apiErr.Message,
"details": apiErr.Details,
})
return
}
// Check for known error types
if errors.Is(err, ErrNotFound) {
w.WriteHeader(404)
json.NewEncoder(w).Encode(map[string]string{
"error": "not found",
})
return
}
if errors.Is(err, ErrUnauthorized) {
w.WriteHeader(401)
json.NewEncoder(w).Encode(map[string]string{
"error": "unauthorized",
})
return
}
// Default to 500
log.Printf("internal error: %v", err)
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{
"error": "internal server error",
})
}
Then in handlers:
func HandleGetUser(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
user, err := GetUser(id)
if err != nil {
HandleError(w, err)
return
}
json.NewEncoder(w).Encode(user)
}
This centralizes error handling and ensures consistent error responses.
Real-World Example
Here’s how these patterns come together in a real service:
package user
import (
"context"
"database/sql"
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("user not found")
ErrInvalidInput = errors.New("invalid input")
)
type Service struct {
db *sql.DB
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
if id == "" {
return nil, fmt.Errorf("user id is required: %w", ErrInvalidInput)
}
query := "SELECT id, name, email FROM users WHERE id = ?"
row := s.db.QueryRowContext(ctx, query, id)
var user User
err := row.Scan(&user.ID, &user.Name, &user.Email)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("failed to query user %s: %w", id, err)
}
return &user, nil
}
func (s *Service) CreateUser(ctx context.Context, user *User) error {
if err := validateUser(user); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
query := "INSERT INTO users (id, name, email) VALUES (?, ?, ?)"
_, err := s.db.ExecContext(ctx, query, user.ID, user.Name, user.Email)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
return nil
}
func validateUser(user *User) error {
var errs ErrorList
if user.Name == "" {
errs = append(errs, &ValidationError{Field: "name", Message: "required"})
}
if user.Email == "" {
errs = append(errs, &ValidationError{Field: "email", Message: "required"})
}
if len(errs) > 0 {
return errs
}
return nil
}
Conclusion
Go’s error handling is verbose, but it’s explicit and predictable. After three years, I appreciate that I can trace exactly where errors come from and how they’re handled.
Key takeaways:
- Always handle errors - use a linter to enforce this
- Wrap errors with context using
%w - Use custom error types for domain-specific errors
- Use sentinel errors for well-known conditions
- Log errors once, at the top level
- Use
paniconly for programmer errors - Clean up resources with
defer
The Go 2 error handling proposal might change some of this, but these patterns work well today. They’ve helped us build reliable services that handle errors gracefully.
Error handling isn’t glamorous, but it’s what separates toy projects from production systems. Invest time in getting it right.