python 81 lines · 3 tabs

Validating JSON Payloads with Pydantic v2 and Returning Field-Level Errors in FastAPI

Shared by codesnips Jul 2026
3 tabs
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator


class CreateUserRequest(BaseModel):
    model_config = {"extra": "forbid"}

    username: str = Field(min_length=3, max_length=32)
    email: EmailStr
    password: str = Field(min_length=8, max_length=128)
    age: Optional[int] = Field(default=None, ge=13, le=120)

    @field_validator("username")
    @classmethod
    def username_is_alphanumeric(cls, value: str) -> str:
        if not value.isalnum():
            raise ValueError("username must be alphanumeric")
        return value.lower()

    @model_validator(mode="after")
    def password_excludes_username(self) -> "CreateUserRequest":
        if self.username in self.password.lower():
            raise ValueError("password must not contain the username")
        return self
3 files · python Explain with highlit

This snippet shows how a FastAPI service validates incoming JSON with Pydantic v2 and turns validation failures into a clean, machine-readable error contract instead of the framework default.

The schemas.py tab defines the request model CreateUserRequest. It goes beyond type coercion: EmailStr enforces a real email shape, Field constraints bound the length of username and password, and age is an optional bounded integer. A field_validator on username rejects non-alphanumeric handles, and a model_validator in after mode enforces a cross-field rule — the password may not contain the username. These validators run in a defined order (field validators first, then the model validator over the assembled instance), which is why the cross-field check can safely assume the individual fields already parsed. model_config with extra="forbid" makes the model reject unknown keys, closing off silent typos and payload smuggling.

The errors.py tab defines the shape returned to clients. FieldError carries a dotted field path, a message, and the Pydantic error type, and flatten_errors walks the loc tuples from exc.errors() to build that path (skipping the leading body segment FastAPI adds). Centralising this keeps the error format identical whether the failure comes from body parsing or a raised ValidationError.

The main.py tab wires it together. The route accepts a validated CreateUserRequest, so by the time the handler body runs the data is already trusted. The interesting part is the exception_handler for RequestValidationError: FastAPI's default 422 payload is verbose and nested, so this handler intercepts it and returns {"errors": [...]} with each field flattened. A second handler catches direct ValidationErrors raised deeper in the stack, giving one consistent envelope.

The trade-off is that overriding the default handler means owning the error format forever, but that predictability is exactly what frontend and mobile clients want. A common pitfall is forgetting extra="forbid", which lets malformed clients pass validation while quietly dropping fields. This pattern fits any JSON API where clients need to map errors back to specific form inputs.


Related snips

Share this code

Here's the card — post it anywhere.

Validating JSON Payloads with Pydantic v2 and Returning Field-Level Errors in FastAPI — share card
Link copied