
Creating Custom Errors in Go – Best Practices
Error handling in Go is one of those topics that seems straightforward at first but reveals layers of complexity as your applications grow. While Go’s explicit error handling approach is refreshing compared to exception-heavy languages, creating meaningful custom errors that provide context, debugging information, and proper handling can make or break your application’s maintainability. This post will walk you through the best practices for crafting custom errors in Go, covering everything from basic implementations to advanced patterns that’ll save you hours of debugging in production.
Understanding Go’s Error Interface
Before diving into custom errors, let’s revisit Go’s built-in error interface. It’s deceptively simple:
type error interface {
Error() string
}
This simplicity is both a blessing and a curse. Any type that implements an `Error()` method satisfies the interface, giving you incredible flexibility. However, this also means you need to think carefully about how to structure your custom errors to provide maximum value.
The standard library provides some basic error creation functions, but they’re quite limited:
// Basic error creation
err1 := errors.New("something went wrong")
err2 := fmt.Errorf("user %s not found", username)
These work fine for simple cases, but real-world applications need more context, better categorization, and structured information that can be programmatically handled.
Step-by-Step Implementation Guide
Let’s build custom errors from the ground up, starting with simple implementations and moving to more sophisticated patterns.
Basic Custom Error Types
The simplest custom error is a string-based type:
type ValidationError string
func (e ValidationError) Error() string {
return string(e)
}
// Usage
func validateEmail(email string) error {
if !strings.Contains(email, "@") {
return ValidationError("invalid email format")
}
return nil
}
This approach works well for simple categorization, but lacks context and flexibility.
Structured Custom Errors
For more complex scenarios, use struct-based errors that can carry additional context:
type DatabaseError struct {
Operation string
Table string
Err error
Code int
}
func (e *DatabaseError) Error() string {
return fmt.Sprintf("database error: %s on table %s (code: %d): %v",
e.Operation, e.Table, e.Code, e.Err)
}
func (e *DatabaseError) Unwrap() error {
return e.Err
}
// Usage
func getUserByID(id int) (*User, error) {
// Simulate database operation
if id < 0 {
return nil, &DatabaseError{
Operation: "SELECT",
Table: "users",
Code: 1001,
Err: errors.New("negative ID not allowed"),
}
}
return nil, nil
}
Notice the `Unwrap()` method - this is crucial for error chain handling introduced in Go 1.13.
Error Wrapping and Chains
Go 1.13 introduced error wrapping, which is essential for maintaining error context through call stacks:
type ServiceError struct {
Service string
Err error
}
func (e *ServiceError) Error() string {
return fmt.Sprintf("%s service error: %v", e.Service, e.Err)
}
func (e *ServiceError) Unwrap() error {
return e.Err
}
// Layered error handling
func processUser(userID int) error {
user, err := getUserByID(userID)
if err != nil {
return &ServiceError{
Service: "UserProcessor",
Err: fmt.Errorf("failed to fetch user %d: %w", userID, err),
}
}
// Process user...
return nil
}
Advanced Error Types with Type Assertions
For sophisticated error handling, create types that can be programmatically inspected:
type APIError struct {
StatusCode int
Message string
Details map[string]interface{}
Timestamp time.Time
}
func (e *APIError) Error() string {
return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
}
func (e *APIError) IsRetryable() bool {
return e.StatusCode >= 500
}
func (e *APIError) IsClientError() bool {
return e.StatusCode >= 400 && e.StatusCode < 500
}
// Smart error handling
func handleAPIError(err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
if apiErr.IsRetryable() {
// Implement retry logic
log.Printf("Retryable error: %v", apiErr)
} else {
log.Printf("Permanent error: %v", apiErr)
}
}
}
Real-World Examples and Use Cases
HTTP Service Error Handling
Here's a practical example for a REST API service:
type HTTPError struct {
StatusCode int `json:"status_code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Timestamp int64 `json:"timestamp"`
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s (request: %s)",
e.StatusCode, e.Message, e.RequestID)
}
func NewHTTPError(code int, message, requestID string) *HTTPError {
return &HTTPError{
StatusCode: code,
Message: message,
RequestID: requestID,
Timestamp: time.Now().Unix(),
}
}
// Middleware for error handling
func errorMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
httpErr := NewHTTPError(500, "Internal server error",
r.Header.Get("X-Request-ID"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpErr.StatusCode)
json.NewEncoder(w).Encode(httpErr)
}
}()
next(w, r)
}
}
Database Connection Pool Error Handling
For database operations, structured errors help with connection management:
type DBConnectionError struct {
Host string
Port int
Database string
Timeout time.Duration
Attempts int
LastAttempt time.Time
}
func (e *DBConnectionError) Error() string {
return fmt.Sprintf("failed to connect to %s:%d/%s after %d attempts (last: %v)",
e.Host, e.Port, e.Database, e.Attempts, e.LastAttempt)
}
func (e *DBConnectionError) ShouldRetry() bool {
return e.Attempts < 3 && time.Since(e.LastAttempt) > 5*time.Second
}
func connectWithRetry(host string, port int, database string) (*sql.DB, error) {
var attempts int
var lastErr error
for attempts < 3 {
attempts++
db, err := sql.Open("postgres", fmt.Sprintf("host=%s port=%d dbname=%s",
host, port, database))
if err == nil {
return db, nil
}
lastErr = err
time.Sleep(time.Duration(attempts) * time.Second)
}
return nil, &DBConnectionError{
Host: host,
Port: port,
Database: database,
Attempts: attempts,
LastAttempt: time.Now(),
}
}
Comparison with Error Handling Approaches
Approach | Pros | Cons | Best Use Case |
---|---|---|---|
Basic errors.New() | Simple, fast, minimal memory | No context, hard to debug | Simple validation, internal functions |
fmt.Errorf() with %w | Error wrapping, contextual info | String-based, limited structure | Adding context to existing errors |
Custom struct errors | Rich context, type-safe handling | More memory, complex setup | API services, database operations |
Error interfaces | Flexible, behavior-based handling | Complex design, potential coupling | Library design, pluggable systems |
Best Practices and Common Pitfalls
Error Creation Best Practices
- Use pointer receivers for error methods: This prevents copying large error structs and allows for nil checks
- Include enough context for debugging: User ID, request ID, timestamp, operation details
- Make errors comparable when possible: Use value types for simple errors that need equality checks
- Implement Unwrap() for error chains: Essential for errors.Is() and errors.As() to work properly
- Don't expose internal implementation details: Keep database-specific error codes internal
// Good: Rich context, proper wrapping
type UserServiceError struct {
UserID int64 `json:"user_id"`
Operation string `json:"operation"`
Timestamp time.Time `json:"timestamp"`
Err error `json:"-"` // Don't serialize wrapped errors
}
func (e *UserServiceError) Error() string {
return fmt.Sprintf("user service error: %s for user %d: %v",
e.Operation, e.UserID, e.Err)
}
func (e *UserServiceError) Unwrap() error {
return e.Err
}
// Bad: Exposing internal details
type BadDatabaseError struct {
SQLQuery string // Don't expose this to clients
InternalConnectionID string // Internal implementation detail
}
Common Pitfalls to Avoid
- Don't ignore error wrapping: Always use %w verb when wrapping errors for proper chain handling
- Avoid overly generic error types: "Error" as a type name provides no useful information
- Don't create errors in hot paths unnecessarily: Error creation has overhead; validate inputs early
- Be careful with error comparison: Use errors.Is() and errors.As() instead of direct comparison
// Common mistake: Breaking error chains
func badWrapping(err error) error {
return fmt.Errorf("operation failed: %v", err) // Wrong: breaks chain
}
func goodWrapping(err error) error {
return fmt.Errorf("operation failed: %w", err) // Correct: preserves chain
}
// Common mistake: Direct error comparison
func badComparison(err error) bool {
return err == sql.ErrNoRows // Fragile, breaks with wrapping
}
func goodComparison(err error) bool {
return errors.Is(err, sql.ErrNoRows) // Correct: works with chains
}
Performance Considerations
Error creation and handling can impact performance, especially in high-throughput services:
// Benchmark results for different error types
// BenchmarkBasicError-8 100000000 10.5 ns/op 0 B/op
// BenchmarkStructError-8 50000000 35.2 ns/op 48 B/op
// BenchmarkWrappedError-8 30000000 45.8 ns/op 64 B/op
// Pre-allocate common errors to avoid allocation overhead
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidInput = errors.New("invalid input")
ErrUnauthorized = errors.New("unauthorized")
)
// Use error variables for frequent, context-free errors
func validateUser(id int64) error {
if id <= 0 {
return ErrInvalidInput // Reuse pre-allocated error
}
return nil
}
Integration with Third-Party Libraries
Many popular Go libraries provide their own error types that you can wrap or extend:
// Working with gorm errors
import "gorm.io/gorm"
type RepositoryError struct {
Repository string
Operation string
EntityID interface{}
Err error
}
func (e *RepositoryError) Error() string {
return fmt.Sprintf("%s repository error in %s for entity %v: %v",
e.Repository, e.Operation, e.EntityID, e.Err)
}
func (e *RepositoryError) Unwrap() error {
return e.Err
}
func getUserByID(db *gorm.DB, id uint) (*User, error) {
var user User
err := db.First(&user, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, &RepositoryError{
Repository: "User",
Operation: "GetByID",
EntityID: id,
Err: err,
}
}
return nil, fmt.Errorf("database query failed: %w", err)
}
return &user, nil
}
Error Logging and Monitoring Integration
Structure your custom errors to work well with logging and monitoring systems:
type MonitorableError struct {
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
Tags map[string]string `json:"tags"`
Metadata map[string]interface{} `json:"metadata"`
Timestamp time.Time `json:"timestamp"`
StackTrace string `json:"stack_trace,omitempty"`
}
func (e *MonitorableError) Error() string {
return fmt.Sprintf("[%s] %s: %s", e.Severity, e.Code, e.Message)
}
func (e *MonitorableError) LogFields() map[string]interface{} {
return map[string]interface{}{
"error_code": e.Code,
"severity": e.Severity,
"tags": e.Tags,
"metadata": e.Metadata,
}
}
// Usage with structured logging
func logError(err error) {
var monErr *MonitorableError
if errors.As(err, &monErr) {
log.WithFields(monErr.LogFields()).Error(monErr.Message)
} else {
log.Error(err.Error())
}
}
Creating effective custom errors in Go requires balancing simplicity with functionality. Start with basic implementations for simple use cases, but don't hesitate to create rich, structured error types for complex applications where debugging and error handling are critical. The key is to provide enough context for debugging while maintaining clean, testable code that integrates well with Go's error handling idioms.
For more detailed information about Go's error handling, check out the official Go documentation on error handling and the Go 1.13 error wrapping blog post.

This article incorporates information and material from various online sources. We acknowledge and appreciate the work of all original authors, publishers, and websites. While every effort has been made to appropriately credit the source material, any unintentional oversight or omission does not constitute a copyright infringement. All trademarks, logos, and images mentioned are the property of their respective owners. If you believe that any content used in this article infringes upon your copyright, please contact us immediately for review and prompt action.
This article is intended for informational and educational purposes only and does not infringe on the rights of the copyright owners. If any copyrighted material has been used without proper credit or in violation of copyright laws, it is unintentional and we will rectify it promptly upon notification. Please note that the republishing, redistribution, or reproduction of part or all of the contents in any form is prohibited without express written permission from the author and website owner. For permissions or further inquiries, please contact us.