Skip to content

Validation Error Handling

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you'll learn about Validation Error Handling. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Validation error handling returns structured, consistent error responses when validation fails, helping clients understand what went wrong and how to fix it.

What You'll Learn

By the end of this lesson, you will implement structured error responses, aggregate field-level errors, add error codes, and handle nested validation errors.

Why It Matters

Poor error handling forces clients to guess what went wrong. Good error responses pinpoints the exact issue, reducing support requests and debug time.

Real-World Use

Stripe's API returns field-level errors: {"error": {"code": "validation_error", "param": "email", "message": "Invalid email address"}}. Clients use the param field to highlight the problematic input.

Error Response Flow

flowchart LR
    Validate[Validation Fails] --> Collect[Collect All Errors]
    Collect --> Format[Format Error Response]
    Format --> Status[400 Bad Request]
    Status --> Client[Client Fixes Errors]
    Client --> Retry[Retry Request]

Structured Error Response

# error_response.py
from typing import Any, Dict, List, Optional

class ValidationError:
    def __init__(self, field: str, message: str, code: str = "invalid",
                 value: Any = None):
        self.field = field
        self.message = message
        self.code = code
        self.value = value

    def to_dict(self) -> Dict:
        result = {
            "field": self.field,
            "message": self.message,
            "code": self.code,
        }
        if self.value is not None:
            result["value"] = self.value
        return result

class ErrorResponse:
    def __init__(self):
        self.errors: List[ValidationError] = []

    def add_error(self, field: str, message: str, code: str = "invalid",
                  value: Any = None):
        self.errors.append(ValidationError(field, message, code, value))

    def add_required(self, field: str):
        self.add_error(field, f"{field} is required", "required")

    def add_type_error(self, field: str, expected: str):
        self.add_error(field, f"{field} must be {expected}", "type_error")

    def to_response(self) -> Dict:
        return {
            "status": 400,
            "error": "Validation failed",
            "details": [e.to_dict() for e in self.errors],
        }

    def ok(self) -> bool:
        return len(self.errors) == 0

resp = ErrorResponse()
resp.add_required("email")
resp.add_type_error("age", "integer")
resp.add_error("password", "Password must be at least 8 characters", "min_length")

resp2 = ErrorResponse()
print(f"With errors: resp.ok={resp.ok()}")
for err in resp.errors:
    print(f"  {err.to_dict()}")

print(f"No errors: resp2.ok={resp2.ok()}")

Expected output:

With errors: resp.ok=False
  {'field': 'email', 'message': 'email is required', 'code': 'required'}
  {'field': 'age', 'message': 'age must be integer', 'code': 'type_error'}
  {'field': 'password', 'message': 'Password must be at least 8 characters', 'code': 'min_length'}
No errors: resp2.ok=True

Field-Level Error Formatter

# field_errors.py
from typing import Any, Dict, List

class FieldErrorFormatter:
    def __init__(self):
        self.errors: Dict[str, List[str]] = {}

    def add(self, field: str, message: str):
        if field not in self.errors:
            self.errors[field] = []
        self.errors[field].append(message)

    def to_flat(self) -> Dict:
        return {
            "errors": [
                {"field": f, "messages": msgs}
                for f, msgs in self.errors.items()
            ]
        }

    def to_nested(self) -> Dict:
        return {
            "errors": {
                field: {"messages": msgs}
                for field, msgs in self.errors.items()
            }
        }

formatter = FieldErrorFormatter()
formatter.add("email", "Required")
formatter.add("email", "Invalid format")
formatter.add("password", "Too short")

print("Flat:")
print(formatter.to_flat())
print("\nNested:")
print(formatter.to_nested())

Expected output:

Flat:
{'errors': [{'field': 'email', 'messages': ['Required', 'Invalid format']}, {'field': 'password', 'messages': ['Too short']}]}

Nested:
{'errors': {'email': {'messages': ['Required', 'Invalid format']}, 'password': {'messages': ['Too short']}}}

Error Code Standards

# error_codes.py
from typing import Any, Dict, List, Optional

class ErrorCode:
    REQUIRED = "required"
    INVALID_FORMAT = "invalid_format"
    TYPE_ERROR = "type_error"
    MIN_LENGTH = "min_length"
    MAX_LENGTH = "max_length"
    MIN_VALUE = "min_value"
    MAX_VALUE = "max_value"
    NOT_UNIQUE = "not_unique"
    PATTERN_MISMATCH = "pattern_mismatch"
    BUSINESS_RULE = "business_rule"

class ErrorCatalog:
    def __init__(self):
        self.messages = {
            ErrorCode.REQUIRED: "{field} is required",
            ErrorCode.INVALID_FORMAT: "{field} format is invalid",
            ErrorCode.TYPE_ERROR: "{field} must be {expected}",
            ErrorCode.MIN_LENGTH: "{field} must be at least {min} characters",
            ErrorCode.MAX_LENGTH: "{field} must be at most {max} characters",
            ErrorCode.MIN_VALUE: "{field} must be at least {min}",
            ErrorCode.MAX_VALUE: "{field} must be at most {max}",
            ErrorCode.NOT_UNIQUE: "{field} already exists",
            ErrorCode.PATTERN_MISMATCH: "{field} format is invalid",
            ErrorCode.BUSINESS_RULE: "{message}",
        }

    def format(self, code: str, **params) -> str:
        template = self.messages.get(code, str(params.get("message", "")))
        return template.format(**params)

    def error(self, field: str, code: str, **params) -> Dict:
        return {
            "field": field,
            "code": code,
            "message": self.format(code, field=field, **params),
        }

catalog = ErrorCatalog()
errors = [
    catalog.error("email", ErrorCode.REQUIRED),
    catalog.error("password", ErrorCode.MIN_LENGTH, min=8),
    catalog.error("age", ErrorCode.MIN_VALUE, min=13),
    catalog.error("username", ErrorCode.NOT_UNIQUE),
]

for e in errors:
    print(f"  [{e['code']:15s}] {e['message']}")

Expected output:

  [required       ] email is required
  [min_length     ] password must be at least 8 characters
  [min_value      ] age must be at least 13
  [not_unique     ] username already exists

Common Mistakes

1. Returning Only One Error at a Time

Clients must fix, retry, fail again, repeat. Return all errors so clients fix everything in one pass.

2. Leaking Internal Details

Error messages like "Database constraint violation on column users.email" expose internals.

3. Inconsistent Error Format

Some endpoints return {"error": "msg"}, others {"errors": ["msg"]}. Standardize.

4. No Error Codes

Messages change over time. Clients should parse error codes, not error strings.

5. Wrong HTTP Status Code

Validation errors use 400 Bad Request. Authentication errors use 401. Authorization uses 403.

Practice Questions

1. What HTTP status code for validation errors?

400 Bad Request.

2. Should error responses include error codes?

Yes. Clients parse codes, not messages. Codes are stable across message changes.

3. How do you handle nested field errors?

Use dot notation: address.street, address.zip. Nest error objects or use flat paths.

4. What is the problem with returning one error at a time?

Clients must make multiple requests to discover all errors. Return all errors at once.

Challenge

Build a validation error handler that converts a flat list of errors into nested field errors and supports localization of error messages.

FAQ

What is the best format for validation errors?

JSON with fields: field (string), code (string), message (string). Stripe and GitHub use this pattern.

Should I include the invalid value in the error?

Only if it does not expose sensitive data. Never include passwords or tokens.

How do I handle multiple errors on one field?

Return an array of error objects per field, one for each violation.

Should validation errors include which validator failed?

Yes, the code field identifies the rule. Clients map codes to UI error messages.

How do I localize error messages?

Use error codes in responses. Let the client map codes to localized messages.

Mini Project: Error Builder

# error_builder.py
from typing import Any, Dict, List

class ErrorBuilder:
    def __init__(self):
        self.items: List[Dict] = []

    def required(self, field: str):
        self.items.append({"field": field, "code": "required"})
        return self

    def invalid(self, field: str, reason: str = "invalid"):
        self.items.append({"field": field, "code": reason})
        return self

    def build(self) -> Dict:
        return {
            "status": 400,
            "error": "Validation failed",
            "details": self.items,
        }

err = ErrorBuilder().required("email").invalid("password", "min_length").build()
print(err)

Expected output:

{'status': 400, 'error': 'Validation failed', 'details': [{'field': 'email', 'code': 'required'}, {'field': 'password', 'code': 'min_length'}]}

What's Next

You understand error handling. Next, learn async validation, then Express validation.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro