Structured Data Validation with Pydantic v2
Pydantic is to Python what data annotations + FluentValidation are to .NET — except it's faster (Rust-backed core in v2), more expressive, and has become the de facto standard for AI application data modeling. The OpenAI SDK, LangChain, FastAPI, and most enterprise AI stacks use Pydantic for schema definition and validation. Learning it is non-negotiable.
BaseModel: The Foundation
Every Pydantic model inherits from BaseModel. Fields are defined as class-level type annotations. Pydantic validates on instantiation and coerces compatible types automatically.
public class ChatRequest
{
[Required]
public string Model { get; set; }
[Range(1, 8192)]
public int MaxTokens { get; set; } = 1024;
[Required, MinLength(1)]
public List<Message> Messages { get; set; }
[Range(0.0, 1.0)]
public float Temperature { get; set; } = 0.7f;
}
from pydantic import BaseModel, Field
from typing import Literal
class ChatRequest(BaseModel):
model: str
max_tokens: int = Field(
default=1024, ge=1, le=8192
)
messages: list[Message]
temperature: float = Field(
default=0.7, ge=0.0, le=1.0
)
class Config:
# Extra fields raise validation error
extra = "forbid"
Validation in Action
This is Pydantic's core value: define a model once and every instantiation is validated and type-coerced automatically — think of it as FluentValidation and model binding combined into the type declaration itself. Watch two things below: a "512" string is silently coerced to an int, and an out-of-range or invalid value raises a ValidationError with a precise field path, exactly the kind of loud failure you want when parsing untrusted LLM output.
from pydantic import BaseModel, Field, ValidationError
from typing import Literal
class Message(BaseModel):
role: Literal["user", "assistant", "system"]
content: str = Field(min_length=1)
class ChatRequest(BaseModel):
model: str
messages: list[Message] = Field(min_length=1)
max_tokens: int = Field(default=1024, ge=1, le=8192)
temperature: float = Field(default=0.7, ge=0.0, le=1.0)
# Valid instantiation — type coercion happens automatically
req = ChatRequest(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello"}], # dict → Message auto-coerced
max_tokens="512", # str → int auto-coerced
)
print(req.max_tokens) # 512 (int, not str)
print(type(req.max_tokens)) #
# Invalid — raises ValidationError with clear messages
try:
bad=ChatRequest( model="gpt-4o" ,
messages=[{"role" :"admin" ,"content" :"test" }], # invalid role
max_tokens=99999, # exceeds le=8192
)
except ValidationError as e:
print(e) # detailed error with field paths and message
Field() for Constraints and Metadata
Field() is where you attach constraints and documentation to a field — bounds (ge, le), string patterns, descriptions, and examples — the declarative equivalent of data-annotation attributes like [Range] and [Required] in .NET. The second pattern, Annotated aliases, lets you define a constrained type like TokenCount once and reuse it across many models instead of repeating the same bounds.
from pydantic import BaseModel, Field
from typing import Annotated
# Method 1: Field() directly
class ModelConfig(BaseModel):
name: str = Field(
..., # ... = required, no default
description="OpenAI model name",
pattern=r"^gpt-", # regex validation
examples=["gpt-4o"],
)
max_tokens: int = Field(default=1024, ge=1, le=8192, description="Max output tokens")
temperature: float = Field(default=0.7, ge=0.0, le=1.0)
top_p: float | None = Field(default=None, ge=0.0, le=1.0)
# Method 2: Annotated (preferred in v2 — reusable)
PositiveInt = Annotated[int, Field(ge=1)]
TokenCount = Annotated[int, Field(ge=0, le=1_000_000)]
ScoreFloat = Annotated[float, Field(ge=0.0, le=1.0)]
class EvalResult(BaseModel):
prompt_id: str
score: ScoreFloat
input_tokens: TokenCount
output_tokens: TokenCount
rank: PositiveInt
Validators: Custom Business Logic
When a constraint can't be expressed as a simple bound, you write a validator method. A @field_validator runs custom logic on one field after type-checking (rejecting whitespace-only queries, say), while a @model_validator sees the whole object and can enforce rules that span fields. This is where domain rules that don't fit an attribute live.
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Any
class RAGRequest(BaseModel):
query: str = Field(min_length=1, max_length=10_000)
top_k: int = Field(default=5, ge=1, le=50)
min_score: float = Field(default=0.7, ge=0.0, le=1.0)
filters: dict[str, str] = Field(default_factory=dict)
@field_validator("query")
@classmethod
def query_not_only_whitespace(cls, v: str) -> str:
"""Field-level validator — runs after type checking."""
stripped = v.strip()
if not stripped:
raise ValueError("query cannot be only whitespace")
return stripped # return the cleaned value
@model_validator(mode="after")
def validate_score_top_k_combo(self) -> "RAGRequest":
"""Model-level validator — access to all fields."""
if self.min_score > 0.95 and self.top_k > 10:
raise ValueError(
"With min_score > 0.95, top_k > 10 is unlikely to return results"
)
return self
# Test validation
try:
RAGRequest(query=" ", top_k=3) # fails field_validator
except Exception as e:
print(e) # 1 validation error for RAGRequest → query → Value error, query cannot be only whitespace
Serialization: to_dict and JSON
Pydantic v2 has fast, built-in serialization. This is critical for AI apps that need to store results, send structured responses, or log data:
from pydantic import BaseModel
from datetime import datetime
import json
class LLMResponse(BaseModel):
text: str
model: str
input_tokens: int
output_tokens: int
finish_reason: str
created_at: datetime = Field(default_factory=datetime.now)
@property
def total_tokens(self) -> int:
return self.input_tokens + self.output_tokens
response = LLMResponse(
text="RAG stands for Retrieval-Augmented Generation...",
model="gpt-4o",
input_tokens=45,
output_tokens=120,
finish_reason="end_turn",
)
# Serialize to dict
d = response.model_dump()
print(d) # {'text': '...', 'model': '...', 'input_tokens': 45, ...}
# Serialize to JSON string
json_str = response.model_dump_json()
print(json_str)
# Control serialization
d_short = response.model_dump(
include={"text", "model"}, # only these fields
exclude={"created_at"}, # exclude these
mode="json", # serialize dates as ISO strings
)
# Deserialize from dict or JSON
same_response = LLMResponse.model_validate(d)
from_json = LLMResponse.model_validate_json(json_str)
Structured Outputs from LLMs
One of Pydantic's most powerful use cases in AI engineering is parsing structured outputs from LLMs. When you ask Claude to return JSON, Pydantic validates and deserializes it in one step:
from pydantic import BaseModel, Field
from openai import OpenAI
import json
class CodeReviewResult(BaseModel):
"""Expected structure for LLM code review responses."""
overall_score: int = Field(ge=1, le=10)
issues: list[str] = Field(default_factory=list)
suggestions: list[str] = Field(default_factory=list)
approved: bool
summary: str
def review_code(client: OpenAI, code: str) -> CodeReviewResult:
"""Ask Claude to review code and parse structured output."""
prompt = f"""Review the following Python code and respond with a JSON object matching this exact schema:
{{
"overall_score": ,"issues" : ["list of specific issues found"],"suggestions" : ["list of improvement suggestions"],"approved" :
= 7>,
"summary": "one-sentence overall assessment"
}}
Code to review:
```python
{code}
```
Respond with ONLY the JSON object, no other text."""
response = client.chat.completions.create(
model="gpt-4o",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
raw_json = response.choices[0].message.content.strip()
# Parse and validate in one step
try:
return CodeReviewResult.model_validate_json(raw_json)
except Exception as e:
raise ValueError(f"LLM returned invalid JSON: {e}\nRaw: {raw_json[:200]}")
# Usage
result = review_code(client, "def add(a, b):\n return a + b")
print(f"Score: {result.overall_score}/10")
print(f"Approved: {result.approved}")
for issue in result.issues:
print(f" - {issue}")
The OpenAI API now supports native tool use and structured outputs that guarantee JSON conforming to a schema. Combined with Pydantic validation, this eliminates JSON parsing errors almost entirely. See the tools/function calling documentation for the latest approach.
Nested Models and Composition
Real API payloads are nested — a response contains usage stats, a list of message objects, and metadata. Pydantic models compose naturally: use one model as the type of a field in another, and validation recurses through the whole tree automatically. This is how you turn a deeply nested JSON blob from an LLM into a fully validated object graph in one Model(**data) call.
from pydantic import BaseModel, Field
from typing import Optional
class TokenUsage(BaseModel):
input_tokens: int
output_tokens: int
@property
def total(self) -> int:
return self.input_tokens + self.output_tokens
class RetrievedSource(BaseModel):
chunk_id: str
document_id: str
score: float = Field(ge=0.0, le=1.0)
text_preview: str = Field(max_length=500)
class RAGResponse(BaseModel):
answer: str
sources: list[RetrievedSource] = Field(default_factory=list)
usage: TokenUsage
model: str
query_embedding_ms: Optional[float] = None
retrieval_ms: Optional[float] = None
generation_ms: Optional[float] = None
@property
def total_latency_ms(self) -> float | None:
parts = [self.query_embedding_ms, self.retrieval_ms, self.generation_ms]
if all(p is not None for p in parts):
return sum(parts) # type: ignore[arg-type]
return None
Key Takeaways
- Pydantic v2 validates on instantiation, coerces compatible types, and raises
ValidationErrorwith detailed field-path messages - Use
Field(ge=..., le=..., min_length=..., pattern=...)for rich constraints — equivalent to data annotations in C# -
@field_validatorfor single-field logic;@model_validator(mode="after")for cross-field invariants -
model_dump()andmodel_dump_json()serialize to dict and JSON;model_validate()andmodel_validate_json()deserialize - Use Pydantic to parse and validate LLM structured outputs — combine with
model_validate_json()to turn raw LLM text into typed objects - Annotated types (
ScoreFloat = Annotated[float, Field(ge=0, le=1)]) create reusable constrained types — use them across multiple models

Comments
Loading comments…