Pydantic v2 Request and Response Models
Learn how Pydantic v2 powers FastAPI's validation, serialization, and OpenAPI generation. Covers BaseModel, Field, model_validator, field_validator, nested models, and custom validators for AI service payloads.
Why Pydantic?
Every AI service receives untrusted input. A user sends JSON ā but is the max_tokens field actually an integer? Is the messages list non-empty? Is the model field one of the allowed values?
Without validation you write if-statements everywhere:
# Without Pydantic ā tedious and error-prone
@app.post("/chat")
async def chat(request: Request):
body = await request.json()
if "messages" not in body:
return JSONResponse({"error": "messages required"}, status_code=422)
if not isinstance(body["messages"], list):
return JSONResponse({"error": "messages must be a list"}, status_code=422)
if len(body["messages"]) == 0:
return JSONResponse({"error": "messages cannot be empty"}, status_code=422)
# ... and so on for every fieldWith Pydantic you declare what the data should look like and Pydantic enforces it automatically:
# With Pydantic ā declarative, automatic, documented
from pydantic import BaseModel, Field
class ChatRequest(BaseModel):
messages: list[dict] # non-empty list is enforced by min_length below
model: str = "gpt-4o"
@app.post("/chat")
async def chat(req: ChatRequest):
... # req is already validated ā safe to usePydantic v2 (released mid-2023, the default in 2026) rewrote the validation engine in Rust via pydantic-core, making validation roughly 5ā50x faster than v1.
BaseModel Basics
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class ArticleModel(BaseModel):
id: int
title: str
body: str
published: bool = False
tags: list[str] = []
created_at: datetime = Field(default_factory=datetime.utcnow)
author_id: Optional[int] = NoneParsing from dict
article = ArticleModel(
id=1,
title="FastAPI Guide",
body="...",
tags=["python", "api"],
)
print(article.model_dump())
# {'id': 1, 'title': 'FastAPI Guide', 'body': '...', 'published': False,
# 'tags': ['python', 'api'], 'created_at': datetime(...), 'author_id': None}Coercion
Pydantic v2 coerces compatible types by default. Sending "123" for an int field raises a validation error in strict mode but converts silently in lax mode (the default).
# Lax mode (default): coerces string "42" to int 42
article = ArticleModel.model_validate({"id": "42", "title": "t", "body": "b"})
print(article.id) # 42 ā coerced from string
# Strict mode: raises ValidationError for wrong types
class StrictArticle(BaseModel):
model_config = {"strict": True}
id: intField: Constraints and Metadata
Field() adds constraints, defaults, and documentation metadata:
from pydantic import BaseModel, Field
class ChatRequest(BaseModel):
messages: list[dict] = Field(
..., # required (no default)
min_length=1, # list must have at least 1 item
description="The conversation history",
)
model: str = Field(
default="gpt-4o",
pattern="^gpt-[a-z0-9-]+$", # regex validation
description="The OpenAI model name",
)
temperature: float = Field(
default=0.7,
ge=0.0, # greater than or equal to 0
le=2.0, # less than or equal to 2
)
max_tokens: int = Field(
default=1024,
ge=1,
le=4096,
)
stream: bool = Field(default=False)Common Field constraints:
| Constraint | Applies To | Meaning |
|-----------|-----------|---------|
| ge=n | int, float | greater than or equal to n |
| gt=n | int, float | strictly greater than n |
| le=n | int, float | less than or equal to n |
| lt=n | int, float | strictly less than n |
| min_length=n | str, list | minimum character / item count |
| max_length=n | str, list | maximum character / item count |
| pattern=r"..." | str | must match regex |
Pydantic v2 Changes from v1
If you are upgrading a service from Pydantic v1, here are the critical changes:
ConfigDict replaces inner Config class
# v1 (deprecated)
class MyModel(BaseModel):
class Config:
orm_mode = True
extra = "forbid"
# v2 (current)
from pydantic import ConfigDict
class MyModel(BaseModel):
model_config = ConfigDict(from_attributes=True, extra="forbid")field_validator replaces @validator
# v1 (deprecated)
from pydantic import validator
class OldModel(BaseModel):
name: str
@validator("name")
def name_must_be_capitalised(cls, v):
return v.capitalize()
# v2 (current)
from pydantic import field_validator
class NewModel(BaseModel):
name: str
@field_validator("name")
@classmethod
def name_must_be_capitalised(cls, v: str) -> str:
return v.capitalize()model_validator replaces root_validator
# v2 model_validator
from pydantic import model_validator
class SearchRequest(BaseModel):
q: str
limit: int = 10
offset: int = 0
@model_validator(mode="after")
def validate_pagination(self) -> "SearchRequest":
if self.offset < 0:
raise ValueError("offset must be non-negative")
if self.limit + self.offset > 10000:
raise ValueError("limit + offset may not exceed 10000")
return selfmode="before" receives the raw dict before field parsing; mode="after" receives the fully-constructed model instance.
.dict() and .json() renamed
# v1 (still works but deprecated)
model.dict()
model.json()
# v2
model.model_dump()
model.model_dump_json()
model.model_dump(mode="json") # converts datetime, UUID etc. to JSON-safe typesNested Models
Nested models let you represent hierarchical JSON without any manual parsing:
from pydantic import BaseModel, Field
from typing import Literal
class Message(BaseModel):
role: Literal["system", "user", "assistant"]
content: str = Field(..., min_length=1, max_length=32000)
class Usage(BaseModel):
prompt_tokens: int
completion_tokens: int
total_tokens: int
class Choice(BaseModel):
index: int
message: Message
finish_reason: Literal["stop", "length", "content_filter", "tool_calls"]
class ChatCompletionResponse(BaseModel):
id: str
object: str = "chat.completion"
created: int
model: str
choices: list[Choice]
usage: UsageFastAPI serialises this to JSON automatically and includes the full nested schema in the OpenAPI docs.
Optional Fields and Defaults
from typing import Optional, Union
from pydantic import BaseModel
class DrugInfoRequest(BaseModel):
drug_name: str
include_interactions: bool = True
max_results: int = 10
language: Optional[str] = None # None means "not provided"
filters: Optional[dict] = None
# All of these are valid:
DrugInfoRequest(drug_name="ibuprofen")
DrugInfoRequest(drug_name="aspirin", language="fr")
DrugInfoRequest(drug_name="metformin", filters={"category": "diabetes"})Request and Response Models for a Chat Endpoint
Here is a complete pair of request/response models for a production AI chat endpoint:
# models/chat.py
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator, ConfigDict
class Message(BaseModel):
role: Literal["system", "user", "assistant"] = Field(
...,
description="The speaker role for this message",
)
content: str = Field(
...,
min_length=1,
max_length=32000,
description="Message content ā text only in this version",
)
@field_validator("content")
@classmethod
def strip_whitespace(cls, v: str) -> str:
return v.strip()
class ChatRequest(BaseModel):
model_config = ConfigDict(extra="forbid") # reject unknown fields
messages: list[Message] = Field(
...,
min_length=1,
description="Conversation history. Must contain at least one message.",
)
model: str = Field(
default="gpt-4o",
description="Deployment name or model ID",
)
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
max_tokens: int = Field(default=1024, ge=1, le=4096)
stream: bool = Field(
default=False,
description="If true, use the /chat/stream endpoint instead",
)
user_id: Optional[str] = Field(
default=None,
description="Caller's user ID for audit logging",
)
@model_validator(mode="after")
def system_message_first(self) -> "ChatRequest":
"""If there is a system message it must be the first message."""
system_indices = [i for i, m in enumerate(self.messages) if m.role == "system"]
if len(system_indices) > 1:
raise ValueError("At most one system message is allowed")
if system_indices and system_indices[0] != 0:
raise ValueError("The system message must be the first message")
return self
class TokenUsage(BaseModel):
prompt_tokens: int
completion_tokens: int
total_tokens: int
class ChatResponse(BaseModel):
content: str
finish_reason: Literal["stop", "length", "content_filter", "tool_calls"]
model: str
usage: TokenUsageCustom Validators: Drug Name and Message Length
Building a drug information service? Here are domain-specific validators:
import re
from pydantic import BaseModel, Field, field_validator, model_validator
ALLOWED_DRUG_NAME_RE = re.compile(r"^[a-zA-Z0-9 \-\(\)\/]+$")
MAX_TOTAL_MESSAGE_CHARS = 64000
class DrugQueryRequest(BaseModel):
drug_name: str = Field(..., min_length=2, max_length=200)
context: Optional[str] = Field(default=None, max_length=2000)
messages: list[Message] = Field(default_factory=list)
@field_validator("drug_name")
@classmethod
def validate_drug_name(cls, v: str) -> str:
v = v.strip()
if not ALLOWED_DRUG_NAME_RE.match(v):
raise ValueError(
"Drug name must contain only letters, numbers, spaces, "
"hyphens, parentheses, or forward slashes."
)
return v
@field_validator("messages")
@classmethod
def validate_message_count(cls, v: list[Message]) -> list[Message]:
if len(v) > 50:
raise ValueError("A single request may not contain more than 50 messages.")
return v
@model_validator(mode="after")
def validate_total_length(self) -> "DrugQueryRequest":
total = sum(len(m.content) for m in self.messages)
if total > MAX_TOTAL_MESSAGE_CHARS:
raise ValueError(
f"Total message content exceeds limit of {MAX_TOTAL_MESSAGE_CHARS} characters. "
f"Current total: {total}."
)
return selfConfigDict Options Useful for AI Services
from pydantic import BaseModel, ConfigDict
class AIResponse(BaseModel):
model_config = ConfigDict(
# Allow instantiation from ORM objects (e.g. SQLAlchemy rows)
from_attributes=True,
# Reject fields not declared in the model ā catches typos
extra="forbid",
# Use enum values in JSON output rather than enum names
use_enum_values=True,
# Include field descriptions in JSON Schema
json_schema_extra={
"examples": [
{
"content": "Ibuprofen is a nonsteroidal anti-inflammatory drug...",
"finish_reason": "stop",
"model": "gpt-4o",
"usage": {
"prompt_tokens": 120,
"completion_tokens": 80,
"total_tokens": 200,
},
}
]
},
)
content: str
finish_reason: str
model: str
usage: TokenUsageValidation Errors and HTTP 422
When FastAPI receives a request body that fails Pydantic validation, it automatically returns HTTP 422 Unprocessable Entity with a structured error body:
{
"detail": [
{
"type": "value_error",
"loc": ["body", "messages"],
"msg": "List should have at least 1 item after validation, not 0",
"input": [],
"url": "https://errors.pydantic.dev/2.6/v/too_short"
}
]
}This happens with zero code from you. Clients get clear, machine-readable error messages.
Key Takeaways
- Pydantic v2 validates incoming JSON, serializes outgoing JSON, and generates OpenAPI schemas from one set of type annotations
Field()adds numeric constraints (ge,le), string constraints (min_length,pattern), and documentation metadata- v2's
field_validatorreplaces@validator;model_validatorreplaces@root_validator;ConfigDictreplaces the innerConfigclass - Use
model_dump()andmodel_dump_json()ā the v1.dict()and.json()methods are deprecated extra="forbid"inConfigDictrejects unknown fields, which is recommended for AI API endpoints- Domain-specific validators (drug name format, message length limits) belong in
field_validatorandmodel_validatorā keep them in your model definitions, not in route handlers
Next lesson: path, query, and body parameters ā how FastAPI routes different parts of the HTTP request to your handler.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.