Go Custom Error Types: Implementing the error Interface
Go's error handling is deliberately simple. The built-in `error` interface requires just one method:
Key Insights
- Go’s error interface requires only one method—
Error() string—making custom error types trivial to implement but powerful for adding context like error codes, timestamps, and structured data - Implementing
Unwrap() errorenables your custom errors to work seamlessly with Go 1.13+ error handling functions likeerrors.Is()anderrors.As(), maintaining error chains through your application - Custom error types shine in domain boundaries like HTTP handlers where you need to translate application errors into protocol-specific responses with appropriate status codes and formatting
Understanding Go’s error Interface
Go’s error handling is deliberately simple. The built-in error interface requires just one method:
type error interface {
Error() string
}
This minimalism is deceptive. While errors.New() and fmt.Errorf() cover basic cases, real applications need richer error information. You’ll want error codes for client APIs, structured logging fields, or domain-specific context that helps debugging and error handling.
Custom error types let you attach this metadata while remaining compatible with Go’s standard error handling. Any type implementing Error() string is an error—no registration, no inheritance hierarchies, just a simple method.
Building Your First Custom Error Type
Start with a struct that captures your domain’s error information:
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)
}
// Usage
func ValidateEmail(email string) error {
if !strings.Contains(email, "@") {
return ValidationError{
Field: "email",
Message: "must contain @ symbol",
}
}
return nil
}
This beats plain strings because callers can inspect the Field and Message programmatically. Your error becomes data, not just text.
Notice we use a value receiver, not a pointer. Error types should typically be values—they’re immutable snapshots of what went wrong. Pointer receivers complicate equality checks and can lead to nil pointer surprises.
Enriching Errors with Structured Fields
Production systems need more than field names and messages. Add error codes for API clients, timestamps for debugging, and operational context:
type AppError struct {
Code string
Message string
Timestamp time.Time
Operation string
UserID string
}
func (e AppError) Error() string {
return fmt.Sprintf("[%s] %s: %s (op=%s, user=%s, time=%s)",
e.Code,
e.Message,
e.Operation,
e.UserID,
e.Timestamp.Format(time.RFC3339),
)
}
func NewAppError(code, message, operation, userID string) AppError {
return AppError{
Code: code,
Message: message,
Timestamp: time.Now(),
Operation: operation,
UserID: userID,
}
}
Now you can log structured fields, return consistent error codes to clients, and trace errors back to specific operations and users. The Error() method provides human-readable output while the struct fields enable programmatic handling.
Error Wrapping and the Unwrap Method
Go 1.13 introduced error wrapping with %w in fmt.Errorf(). Custom errors should participate in this chain by implementing Unwrap():
type DatabaseError struct {
Query string
Err error
}
func (e DatabaseError) Error() string {
return fmt.Sprintf("database error executing query '%s': %v", e.Query, e.Err)
}
func (e DatabaseError) Unwrap() error {
return e.Err
}
// Usage
func GetUser(id int) (*User, error) {
query := "SELECT * FROM users WHERE id = ?"
var user User
err := db.QueryRow(query, id).Scan(&user)
if err != nil {
return nil, DatabaseError{
Query: query,
Err: err,
}
}
return &user, nil
}
With Unwrap() implemented, you can use errors.Is() and errors.As() to check the wrapped error:
user, err := GetUser(42)
if err != nil {
// Check if the underlying error is sql.ErrNoRows
if errors.Is(err, sql.ErrNoRows) {
return nil, nil // User not found, return nil without error
}
// Extract the DatabaseError for logging
var dbErr DatabaseError
if errors.As(err, &dbErr) {
log.Printf("Failed query: %s", dbErr.Query)
}
return nil, err
}
This maintains error context while allowing inspection of the error chain. You get the best of both worlds: rich custom errors and compatibility with standard error checking.
Sentinel Errors and Type-Based Error Handling
Sentinel errors are predeclared error variables used for specific conditions:
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized access")
ErrInvalidInput = errors.New("invalid input")
)
func FindResource(id string) error {
if id == "" {
return ErrInvalidInput
}
// ... lookup logic
return ErrNotFound
}
Combine sentinel errors with custom types for maximum flexibility:
type NotFoundError struct {
ResourceType string
ResourceID string
}
func (e NotFoundError) Error() string {
return fmt.Sprintf("%s not found: %s", e.ResourceType, e.ResourceID)
}
func (e NotFoundError) Is(target error) bool {
return target == ErrNotFound
}
// Usage
err := NotFoundError{ResourceType: "user", ResourceID: "123"}
errors.Is(err, ErrNotFound) // true - custom Is() method enables this
By implementing Is(), your custom error can match sentinel errors while carrying additional context. This is powerful for API boundaries where you need both structured data and simple error comparisons.
For extracting custom error details, use errors.As():
var notFoundErr NotFoundError
if errors.As(err, ¬FoundErr) {
log.Printf("Missing %s: %s", notFoundErr.ResourceType, notFoundErr.ResourceID)
}
Real-World Example: HTTP API Error Handling
Custom errors excel at domain boundaries. Here’s how to use them in an HTTP API:
type APIError struct {
StatusCode int
Code string
Message string
Details map[string]string
}
func (e APIError) Error() string {
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func NewNotFoundError(resource string) APIError {
return APIError{
StatusCode: http.StatusNotFound,
Code: "NOT_FOUND",
Message: fmt.Sprintf("%s not found", resource),
}
}
func NewValidationError(details map[string]string) APIError {
return APIError{
StatusCode: http.StatusBadRequest,
Code: "VALIDATION_ERROR",
Message: "Request validation failed",
Details: details,
}
}
func ErrorHandler(w http.ResponseWriter, err error) {
var apiErr APIError
if errors.As(err, &apiErr) {
w.WriteHeader(apiErr.StatusCode)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"code": apiErr.Code,
"message": apiErr.Message,
"details": apiErr.Details,
},
})
return
}
// Fallback for unexpected errors
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred",
},
})
}
// Handler usage
func GetUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
ErrorHandler(w, NewValidationError(map[string]string{
"id": "required parameter missing",
}))
return
}
user, err := GetUser(id)
if err != nil {
ErrorHandler(w, NewNotFoundError("user"))
return
}
json.NewEncoder(w).Encode(user)
}
This pattern centralizes error-to-HTTP translation. Your business logic returns domain errors; the HTTP layer converts them to appropriate responses. Clean separation of concerns.
Best Practices and Pitfalls
Use value receivers for Error() methods. Pointer receivers create subtle bugs with nil checks and error comparison. The exception: if your error type is large or contains mutexes, but this is rare.
Name error types descriptively. ValidationError, DatabaseError, and NotFoundError are clear. Avoid generic names like CustomError or MyError.
Don’t overuse custom errors. If you’re just wrapping a message, use fmt.Errorf(). Custom types add value when you need structured fields, specific error handling, or domain-specific behavior.
Make error construction easy. Provide constructor functions like NewValidationError() that set defaults and ensure consistent error creation.
Consider implementing Is() for sentinel compatibility. This lets your rich custom errors match simple sentinel errors, giving callers flexibility in how they check errors.
Export error types, not always error values. Callers need access to your error types for errors.As(). Sentinel error variables can be exported or internal depending on your API design.
Custom error types transform Go’s simple error interface into a powerful tool for building maintainable systems. They carry context through your application, enable sophisticated error handling at boundaries, and keep your code clean and testable. Start simple—add an Error() method—then enhance with fields, wrapping, and domain-specific behavior as your needs grow.