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
from typing import Any, Iterable
from pydantic import BaseModel
class FieldError(BaseModel):
field: str
message: str
type: str
def flatten_errors(raw_errors: Iterable[dict[str, Any]]) -> list[FieldError]:
flattened: list[FieldError] = []
for err in raw_errors:
loc = [str(part) for part in err.get("loc", ())]
if loc and loc[0] in ("body", "query", "path"):
loc = loc[1:]
flattened.append(
FieldError(
field=".".join(loc) or "__root__",
message=err.get("msg", "invalid value"),
type=err.get("type", "value_error"),
)
)
return flattened
from fastapi import FastAPI, Request, status
fastapi_status = status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from errors import flatten_errors
from schemas import CreateUserRequest
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def handle_request_validation(request: Request, exc: RequestValidationError):
errors = [e.model_dump() for e in flatten_errors(exc.errors())]
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"errors": errors},
)
@app.exception_handler(ValidationError)
async def handle_pydantic_validation(request: Request, exc: ValidationError):
errors = [e.model_dump() for e in flatten_errors(exc.errors())]
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"errors": errors},
)
@app.post("/users", status_code=status.HTTP_201_CREATED)
async def create_user(payload: CreateUserRequest):
return {"id": 1, "username": payload.username, "email": payload.email}
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
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
package com.example.myapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
Dependency injection with Hilt
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.