Go’s approach to error handling is straightforward but can lead to repetitive code patterns. After years of writing Go code, I’ve discovered several techniques that go beyond the basic if err != nil
pattern. These approaches have helped me build more robust applications while making error handling more elegant and maintainable.
The Problem with Traditional Error Handling
The standard Go error handling pattern looks deceptively simple:
result, err := someFunction()
if err != nil {
return nil, err
}
When you have dozens of these checks in a single function, your code becomes cluttered with error handling logic. This obscures the main business logic and creates maintenance challenges. Let’s explore better alternatives.
Functional Options with Error Validation
Functional options provide a clean way to configure objects while validating inputs during initialization rather than at runtime:
type ClientOption func(*Client) error
type Client struct {
timeout time.Duration
retries int
}
func WithTimeout(timeout time.Duration) ClientOption {
return func(c *Client) error {
if timeout <= 0 {
return fmt.Errorf("timeout must be positive")
}
c.timeout = timeout
return nil
}
}
func WithRetries(retries int) ClientOption {
return func(c *Client) error {
if retries < 0 {
return fmt.Errorf("retries cannot be negative")
}
c.retries = retries
return nil
}
}
func NewClient(options ...ClientOption) (*Client, error) {
client := &Client{
timeout: 30 * time.Second, // Default values
retries: 1,
}
for _, option := range options {
if err := option(client); err != nil {
return nil, fmt.Errorf("client configuration error: %w", err)
}
}
return client, nil
}
This pattern catches configuration issues early, providing clear error messages about what went wrong. I find this much more maintainable than checking numerous parameters in a constructor function.
Error Wrapping with Context
Go 1.13 introduced error wrapping, which lets you preserve the original error while adding context:
func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("opening %s: %w", path, err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("reading %s: %w", path, err)
}
if err := process(data); err != nil {
return fmt.Errorf("processing %s: %w", path, err)
}
return nil
}
The %w
verb wraps the original error, maintaining the error chain. When this technique is used throughout your codebase, you get rich error information that helps pinpoint issues.
Sentinel Errors
Predefined sentinel errors allow consumers of your package to check for specific error conditions:
package database
import "errors"
var (
ErrNotFound = errors.New("record not found")
ErrDuplicate = errors.New("duplicate record")
ErrTimeout = errors.New("operation timed out")
)
func Get(id string) (Record, error) {
// Implementation...
if recordNotInDB(id) {
return Record{}, ErrNotFound
}
// ...
}
Then consumers can check for specific conditions:
record, err := database.Get("user-123")
if errors.Is(err, database.ErrNotFound) {
// Handle not found case
return
}
if err != nil {
// Handle other errors
return
}
I use this pattern for expected error conditions that callers might want to handle differently.
Custom Error Types
For more complex error scenarios, custom error types provide structured error data:
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("validation error on field %q: %s", e.Field, e.Message)
}
func validateUser(user User) error {
if user.Name == "" {
return ValidationError{
Field: "name",
Message: "name cannot be empty",
}
}
if len(user.Password) < 8 {
return ValidationError{
Field: "password",
Message: "password must be at least 8 characters",
}
}
return nil
}
Consumers can extract specific information:
err := validateUser(user)
var validationErr ValidationError
if errors.As(err, &validationErr) {
fmt.Printf("Failed validation on field: %s\n", validationErr.Field)
}
I prefer this approach when errors need to carry structured data that’s useful for both logging and client responses.
Error Handling with Result Types
When working with functions that return values and errors, the Result pattern can clean up your code:
type Result[T any] struct {
Value T
Err error
}
func (r Result[T]) Unwrap() (T, error) {
return r.Value, r.Err
}
func FetchUser(id string) Result[User] {
user, err := database.GetUser(id)
return Result[User]{Value: user, Err: err}
}
func GetUserDetails(id string) Result[UserDetails] {
userResult := FetchUser(id)
if userResult.Err != nil {
return Result[UserDetails]{Err: userResult.Err}
}
user := userResult.Value
details, err := enrichUserData(user)
return Result[UserDetails]{Value: details, Err: err}
}
This pattern shines when you need to chain multiple operations that might fail. It separates the error handling from the business logic flow.
Selective Error Exposure
Not all internal errors should be exposed to API consumers. This pattern maps internal errors to public-facing ones:
func mapError(err error) error {
var dbErr *database.DBError
if errors.As(err, &dbErr) {
switch dbErr.Code {
case database.CodeConnectionFailed:
return errors.New("service temporarily unavailable")
case database.CodeTimeout:
return errors.New("request timed out")
default:
return errors.New("internal server error")
}
}
// For security, don't expose validation or internal errors
if strings.Contains(err.Error(), "SQL syntax") {
return errors.New("internal server error")
}
return err
}
func handleAPIRequest(w http.ResponseWriter, r *http.Request) {
result, err := processRequest(r)
if err != nil {
publicErr := mapError(err)
http.Error(w, publicErr.Error(), errorToStatusCode(publicErr))
return
}
// Handle success case...
}
I use this approach in all public-facing APIs to prevent leaking sensitive implementation details.
Circuit Breakers
Circuit breakers prevent cascading failures by temporarily failing fast when a dependency is experiencing issues:
type CircuitBreaker struct {
failures int
threshold int
resetTimeout time.Duration
lastFailure time.Time
mutex sync.Mutex
}
func NewCircuitBreaker(threshold int, resetTimeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
threshold: threshold,
resetTimeout: resetTimeout,
}
}
func (cb *CircuitBreaker) Execute(fn func() error) error {
cb.mutex.Lock()
if cb.failures >= cb.threshold {
if time.Since(cb.lastFailure) > cb.resetTimeout {
// Circuit half-open, allow one request through
cb.mutex.Unlock()
} else {
cb.mutex.Unlock()
return errors.New("circuit open")
}
} else {
cb.mutex.Unlock()
}
err := fn()
if err != nil {
cb.mutex.Lock()
cb.failures++
cb.lastFailure = time.Now()
cb.mutex.Unlock()
return err
}
cb.mutex.Lock()
cb.failures = 0 // Reset on success
cb.mutex.Unlock()
return nil
}
Usage:
breaker := NewCircuitBreaker(5, 1*time.Minute)
func callExternalService() error {
return breaker.Execute(func() error {
return httpClient.Get("https://external-service.com/api")
})
}
I’ve used this pattern extensively in microservice architectures to prevent failures in one service from affecting the entire system.
Errors as Values
Rather than just checking for errors, sometimes treating errors as data structures allows for more sophisticated error handling:
type ErrorCollector struct {
Errors []error
}
func (ec *ErrorCollector) Add(err error) {
if err != nil {
ec.Errors = append(ec.Errors, err)
}
}
func (ec *ErrorCollector) HasErrors() bool {
return len(ec.Errors) > 0
}
func (ec *ErrorCollector) Error() string {
var messages []string
for _, err := range ec.Errors {
messages = append(messages, err.Error())
}
return strings.Join(messages, "; ")
}
func validateData(data Data) error {
collector := &ErrorCollector{}
if data.Name == "" {
collector.Add(errors.New("name is required"))
}
if data.Age < 0 {
collector.Add(errors.New("age cannot be negative"))
}
if len(data.Email) > 0 && !strings.Contains(data.Email, "@") {
collector.Add(errors.New("invalid email format"))
}
if collector.HasErrors() {
return collector
}
return nil
}
I use this approach for validation scenarios where I want to collect all errors rather than stopping at the first one.
Contextual Errors with Structured Logging
Combining structured errors with a logging system enhances debugging:
type OperationError struct {
Operation string
Err error
Context map[string]interface{}
}
func (e *OperationError) Error() string {
return fmt.Sprintf("%s: %v", e.Operation, e.Err)
}
func (e *OperationError) Unwrap() error {
return e.Err
}
func processPayment(payment Payment) error {
userID := payment.UserID
amount := payment.Amount
err := chargeCard(payment)
if err != nil {
return &OperationError{
Operation: "process_payment",
Err: err,
Context: map[string]interface{}{
"user_id": userID,
"amount": amount,
"time": time.Now(),
},
}
}
return nil
}
// In your logging middleware
func logError(err error) {
var opErr *OperationError
if errors.As(err, &opErr) {
logger.WithFields(opErr.Context).Error(opErr.Error())
} else {
logger.Error(err.Error())
}
}
I’ve found this especially valuable for troubleshooting issues in production systems, as it captures the relevant context at the time of the error.
Retry with Backoff
Some errors are transient and can be resolved by retrying the operation:
func retryWithBackoff(maxRetries int, op func() error) error {
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
err = op()
if err == nil {
return nil
}
// Check if error is retryable
if isRetryable(err) {
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
time.Sleep(backoff)
continue
}
// If not retryable, return immediately
return err
}
return fmt.Errorf("operation failed after %d attempts: %w", maxRetries, err)
}
func isRetryable(err error) bool {
// Define logic to determine if an error is retryable
return errors.Is(err, io.ErrUnexpectedEOF) ||
errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(err.Error(), "connection reset")
}
func fetchData() error {
return retryWithBackoff(5, func() error {
_, err := http.Get("https://api.example.com/data")
return err
})
}
I use this pattern for network operations and other scenarios where transient failures are common.
Conclusion
These error handling patterns have transformed how I write Go code. Instead of viewing error handling as a necessary evil, I now see it as an integral part of a robust application architecture.
By using these patterns appropriately, you can:
- Make your code more readable by separating error handling from business logic
- Provide richer context for troubleshooting issues
- Create more resilient systems that gracefully handle failures
- Build APIs with consistent error behavior
The right pattern depends on your specific needs, but having these tools in your toolkit will dramatically improve how you handle errors in Go. Remember, good error handling isn’t just about checking for nil—it’s about creating systems that are easier to debug, maintain, and enhance over time.