Skip to main content

Pydantic

Pydantic is the data validation and modeling layer at the boundary of every AI system we build. It validates API inputs, structures LLM outputs, defines agent state, and serializes data between services β€” all with a single, consistent API.

This article covers Pydantic v2 exclusively. The v2 API is a ground-up rewrite in Rust and is not backward-compatible with v1. If you see validator (singular) or dict() methods in code, you are looking at v1.


Why Pydantic​

Validation at the Boundary​

In AI engineering, untrusted data flows everywhere:

  • API inputs β€” user queries, configuration, tool arguments arrive as JSON and must be validated before processing.
  • Tool outputs β€” LLM tool calls return strings that need parsing and validation before your code trusts them.
  • Agent state β€” LangGraph checkpoints serialize and deserialize state through Pydantic models.
  • Search results β€” Azure AI Search returns semi-structured data that must conform to a schema before downstream use.

Pydantic enforces these boundaries. Without it, you write manual validation logic that drifts, duplicates, and breaks silently.

Serialization Out of the Box​

Pydantic models convert to and from dictionaries, JSON, and custom formats with zero boilerplate. .model_dump(), .model_validate(), and .model_dump_json() handle the round-trip between Python objects and wire formats.

Ecosystem Integration​

Pydantic is the shared data layer across our stack:

FrameworkHow Pydantic Fits
FastAPIRequest/response bodies, query parameters, and path parameters are Pydantic models
LangChainwith_structured_output() returns Pydantic models; tool schemas are Pydantic
LangGraphAgent state is defined as a Pydantic BaseModel; checkpoints serialize through it
Azure AI SearchResult parsing uses Pydantic to validate document schemas

Pydantic vs. Alternatives​

Why not dataclasses, TypedDict, or attrs? Here is the comparison:

FeaturePydantic BaseModeldataclassesTypedDictattrs
Runtime validationYes β€” automaticNoNoNo (without validators)
Nested validationYes β€” recursiveNoNoManual
Serialization.model_dump(), .model_dump_json()Manual (asdict)ManualManual
Deserialization.model_validate() from dict/JSONManualManualManual
Custom validators@field_validator, @model_validatorNoNo@validator (limited)
Type coercionYes ("42" β†’ 42)NoNoNo
Default valuesYesYesYesYes
Immutabilityfrozen=Truefrozen=TrueNofrozen=True
PerformanceRust-backed (fast)Pure PythonPure PythonPure Python
AI ecosystem fitFirst-classLimitedLimitedLimited

For AI engineering, Pydantic is the only option that gives you validation, serialization, and ecosystem integration in one package.


Basic Models​

Defining a Model​

Every Pydantic model inherits from BaseModel. You declare fields as class attributes with type annotations:

from pydantic import BaseModel


class UserInput(BaseModel):
query: str
max_results: int = 5
language: str = "en"

Instantiation triggers validation:

# βœ… Valid β€” all fields present
input = UserInput(query="Python async patterns", max_results=10)

# βœ… Valid β€” defaults fill in missing fields
input = UserInput(query="LangChain agents")

# ❌ ValidationError β€” type mismatch
# input = UserInput(query="test", max_results="ten")

Field Types and Validation​

Pydantic validates types at instantiation time. It also coerces where safe:

from pydantic import BaseModel


class SearchConfig(BaseModel):
query: str
max_tokens: int
temperature: float
top_k: int


# Coercion works for compatible types
config = SearchConfig(
query="AI engineering",
max_tokens="1024", # str β†’ int (coerced)
temperature=0.7,
top_k=5,
)
print(config.max_tokens) # 1024 (int, not str)

# Incompatible types raise ValidationError
# config = SearchConfig(query="test", max_tokens="abc", temperature=0.7, top_k=5)
# β†’ ValidationError: 1 validation error for SearchConfig
# max_tokens
# Input should be a valid integer, unable to parse string as an integer

Field Constraints​

Use Field() to add constraints beyond type:

from pydantic import BaseModel, Field


class LLMConfig(BaseModel):
model_name: str = Field(
min_length=1,
max_length=128,
pattern=r"^(gpt-|claude-|o[1-3])",
description="The model identifier to use for generation",
)
temperature: float = Field(ge=0.0, le=2.0, default=0.7)
max_tokens: int = Field(gt=0, le=128_000, default=4096)
stop_sequences: list[str] = Field(default_factory=list)


# βœ… Valid
config = LLMConfig(model_name="gpt-4o", temperature=0.3, max_tokens=2048)

# ❌ ValidationError β€” temperature out of range
# config = LLMConfig(model_name="gpt-4o", temperature=2.5)

# ❌ ValidationError β€” model_name does not match pattern
# config = LLMConfig(model_name="custom-model-xyz")

# ❌ ValidationError β€” max_tokens must be > 0
# config = LLMConfig(model_name="gpt-4o", max_tokens=0)

Default Values and Optional Fields​

from pydantic import BaseModel, Field
from typing import Optional


class ToolCall(BaseModel):
tool_name: str
arguments: dict[str, object]
confidence: float = Field(default=1.0, ge=0.0, le=1.0)
metadata: Optional[dict[str, str]] = None


# Optional field omitted β€” defaults to None
call = ToolCall(tool_name="search", arguments={"query": "Python"})
print(call.metadata) # None

# Optional field provided
call = ToolCall(
tool_name="search",
arguments={"query": "Python"},
metadata={"source": "user_request"},
)

Do and Don't​

from pydantic import BaseModel, Field


# βœ… DO β€” explicit types, constraints, and defaults
class QueryRequest(BaseModel):
query: str = Field(min_length=1, max_length=500)
max_results: int = Field(default=5, ge=1, le=50)
include_metadata: bool = False


# ❌ DON'T β€” no validation, no constraints
class QueryRequestWeak(BaseModel):
query: str
max_results: int
include_metadata: bool


# βœ… DO β€” use Optional for truly optional fields
class FilterOptions(BaseModel):
date_range: Optional[tuple[str, str]] = None
categories: list[str] = Field(default_factory=list)


# ❌ DON'T β€” mutable default values as field defaults
class BadModel(BaseModel):
tags: list[str] = [] # Dangerous β€” shared mutable default


# βœ… DO β€” use default_factory for mutable defaults
class GoodModel(BaseModel):
tags: list[str] = Field(default_factory=list)
Mutable Default Values

Never use mutable objects (list, dict, set) as default values directly. Use Field(default_factory=list) or Field(default_factory=dict) instead. While Pydantic handles this more gracefully than plain Python, it is still a source of subtle bugs.


Nested Models​

Nested Model Definitions​

Models can contain other models as fields. Validation is recursive:

from pydantic import BaseModel, Field


class SearchResult(BaseModel):
title: str
url: str
snippet: str = Field(max_length=500)
relevance_score: float = Field(ge=0.0, le=1.0)


class SearchResponse(BaseModel):
query: str
results: list[SearchResult]
total_count: int = Field(ge=0)
processing_time_ms: float = Field(gt=0)


response = SearchResponse(
query="Pydantic validation",
results=[
SearchResult(
title="Pydantic v2 Docs",
url="https://docs.pydantic.dev",
snippet="Data validation using Python type annotations.",
relevance_score=0.95,
),
],
total_count=1,
processing_time_ms=42.5,
)

Lists of Models​

Lists of nested models are common in AI workflows β€” search results, tool outputs, conversation turns:

from pydantic import BaseModel, Field
from typing import Literal


class ChatMessage(BaseModel):
role: Literal["human", "ai", "tool"]
content: str
tool_call_id: str | None = None


class ConversationState(BaseModel):
messages: list[ChatMessage] = Field(default_factory=list)
turn_count: int = Field(default=0, ge=0)
is_complete: bool = False


state = ConversationState(
messages=[
ChatMessage(role="human", content="Search for Python best practices"),
ChatMessage(role="ai", content="I'll search for that."),
ChatMessage(role="tool", content="Found 12 results", tool_call_id="call_abc123"),
],
turn_count=3,
)

Discriminated Unions​

When a field can be one of several types, use Union with a discriminator to tell Pydantic which type to validate against:

from pydantic import BaseModel, Field
from typing import Annotated, Literal


class WebSearchResult(BaseModel):
type: Literal["web"]
title: str
url: str
snippet: str


class KnowledgeBaseResult(BaseModel):
type: Literal["knowledge_base"]
document_id: str
content: str
source: str


SearchResult = Annotated[
WebSearchResult | KnowledgeBaseResult,
Field(discriminator="type"),
]


class HybridSearchResponse(BaseModel):
query: str
results: list[SearchResult]


response = HybridSearchResponse(
query="Python async patterns",
results=[
WebSearchResult(
type="web",
title="AsyncIO Documentation",
url="https://docs.python.org/3/library/asyncio.html",
snippet="Asynchronous I/O support.",
),
KnowledgeBaseResult(
type="knowledge_base",
document_id="doc_42",
content="Use asyncio.gather for parallel calls.",
source="internal_wiki",
),
],
)
Discriminated Unions in AI

Discriminated unions are ideal for representing heterogeneous tool outputs, multi-modal search results, or different agent step types. The type discriminator field tells Pydantic which model to validate against without guessing.


Validation​

Field Validators​

@field_validator runs custom validation logic on individual fields:

from pydantic import BaseModel, Field, field_validator


class LLMParameters(BaseModel):
model_name: str
temperature: float = Field(ge=0.0, le=2.0)
max_tokens: int = Field(gt=0)
stop_sequences: list[str] = Field(default_factory=list)

@field_validator("model_name")
@classmethod
def validate_model_name(cls, v: str) -> str:
"""Model names must match supported prefixes."""
allowed_prefixes = ("gpt-", "o", "claude-", "o3-")
if not any(v.startswith(prefix) for prefix in allowed_prefixes):
raise ValueError(
f"Unsupported model: {v}. Must start with one of: {allowed_prefixes}"
)
return v

@field_validator("stop_sequences")
@classmethod
def validate_stop_sequences(cls, v: list[str]) -> list[str]:
"""Stop sequences must not contain empty strings."""
if any(not seq.strip() for seq in v):
raise ValueError("Stop sequences must not be empty or whitespace-only")
return v


# βœ… Valid
params = LLMParameters(
model_name="gpt-4o",
temperature=0.7,
max_tokens=4096,
stop_sequences=["\nUser:", "\nAssistant:"],
)

# ❌ ValidationError β€” unsupported model name
# params = LLMParameters(model_name="custom-model", temperature=0.7, max_tokens=100)
# β†’ ValueError: Unsupported model: custom-model

# ❌ ValidationError β€” empty stop sequence
# params = LLMParameters(
# model_name="gpt-4o",
# temperature=0.7,
# max_tokens=100,
# stop_sequences=["\nUser:", ""],
# )
# β†’ ValueError: Stop sequences must not be empty or whitespace-only

Model Validators​

@model_validator validates across multiple fields. Use mode="after" to run after field validation, or mode="before" to run before:

from pydantic import BaseModel, Field, model_validator


class TokenBudget(BaseModel):
max_input_tokens: int = Field(gt=0)
max_output_tokens: int = Field(gt=0)
context_window: int = Field(gt=0)

@model_validator(mode="after")
def validate_token_budget(self) -> "TokenBudget":
"""Input + output tokens must fit within the context window."""
total = self.max_input_tokens + self.max_output_tokens
if total > self.context_window:
raise ValueError(
f"Token budget exceeded: {total} > context window {self.context_window}. "
f"Reduce max_input_tokens or max_output_tokens."
)
return self


# βœ… Valid β€” 8192 + 4096 = 12288 <= 128000
budget = TokenBudget(
max_input_tokens=8192,
max_output_tokens=4096,
context_window=128_000,
)

# ❌ ValidationError β€” budget exceeds context window
# budget = TokenBudget(
# max_input_tokens=120_000,
# max_output_tokens=20_000,
# context_window=128_000,
# )
# β†’ ValueError: Token budget exceeded: 140000 > context window 128000

BeforeValidator and AfterValidator​

For inline validation without decorator syntax, use BeforeValidator and AfterValidator with Annotated:

from pydantic import BaseModel, BeforeValidator, AfterValidator
from typing import Annotated


def strip_and_lower(v: str) -> str:
return v.strip().lower()


def check_not_empty(v: str) -> str:
if not v:
raise ValueError("Query must not be empty after trimming")
return v


CleanQuery = Annotated[
str,
BeforeValidator(strip_and_lower),
AfterValidator(check_not_empty),
]


class SearchRequest(BaseModel):
query: CleanQuery
max_results: int = 5


# βœ… Valid β€” whitespace trimmed, lowercased
req = SearchRequest(query=" Pydantic ")
print(req.query) # "pydantic"

# ❌ ValidationError β€” empty after trim
# req = SearchRequest(query=" ")
# β†’ ValueError: Query must not be empty after trimming

Custom Exception Handling​

Pydantic ValidationError gives structured access to errors:

from pydantic import BaseModel, ValidationError


class ToolOutput(BaseModel):
tool_name: str
result: str
is_error: bool = False


def parse_tool_output(raw: dict) -> ToolOutput:
"""Parse and validate tool output from LLM."""
try:
return ToolOutput.model_validate(raw)
except ValidationError as exc:
# Log structured error details
for error in exc.errors():
print(f"Field: {error['loc']}, Issue: {error['msg']}")
raise RuntimeError(f"Tool output validation failed: {exc}") from exc


# This raises RuntimeError with details
# parse_tool_output({"tool_name": "search"}) # missing 'result' field

Serialization​

Converting to Dictionary and JSON​

from pydantic import BaseModel, Field


class AgentState(BaseModel):
query: str
steps_completed: int = 0
findings: list[str] = Field(default_factory=list)
is_done: bool = False


state = AgentState(
query="Research Python async patterns",
steps_completed=3,
findings=["Found asyncio docs", "Found LangChain examples"],
)

# Convert to dictionary
data: dict = state.model_dump()
# {'query': 'Research Python async patterns', 'steps_completed': 3, ...}

# Convert to JSON string
json_str: str = state.model_dump_json()
# '{"query":"Research Python async patterns","steps_completed":3,...}'

Deserializing from Dictionary and JSON​

# From dictionary
state = AgentState.model_validate({
"query": "Research LangGraph",
"steps_completed": 1,
"findings": ["Found LangGraph docs"],
})

# From JSON string
state = AgentState.model_validate_json(
'{"query": "Research LangGraph", "steps_completed": 1, "findings": ["Found LangGraph docs"]}'
)

Serialization Modes​

mode='python' (default) returns Python-native types. mode='json' returns JSON-serializable types:

from datetime import datetime
from pydantic import BaseModel


class TimestampedEvent(BaseModel):
event_type: str
timestamp: datetime


event = TimestampedEvent(
event_type="tool_call",
timestamp=datetime(2025, 7, 8, 14, 30, 0),
)

# mode='python' β€” datetime object
python_data = event.model_dump(mode="python")
print(type(python_data["timestamp"])) # <class 'datetime.datetime'>

# mode='json' β€” ISO 8601 string
json_data = event.model_dump(mode="json")
print(type(json_data["timestamp"])) # <class 'str'>
print(json_data["timestamp"]) # '2025-07-08T14:30:00'

Partial Serialization​

Use exclude and include to control what gets serialized:

from pydantic import BaseModel, Field, SecretStr


class APIConfig(BaseModel):
endpoint: str
api_key: SecretStr
timeout: float = 30.0
headers: dict[str, str] = Field(default_factory=dict)


config = APIConfig(
endpoint="https://api.example.com",
api_key="sk-secret-key-12345",
timeout=15.0,
headers={"X-Request-ID": "req-001"},
)

# Exclude sensitive fields
safe_dump = config.model_dump(exclude={"api_key"})
# {'endpoint': '...', 'timeout': 15.0, 'headers': {...}}

# Include only specific fields
minimal_dump = config.model_dump(include={"endpoint", "timeout"})
# {'endpoint': '...', 'timeout': 15.0}

# SecretStr is always redacted in model_dump
print(config.model_dump())
# {'endpoint': '...', 'api_key': '**********', ...}

Aliases for API Response Shaping​

Use validation_alias and serialization_alias to map between internal field names and external API names:

from pydantic import BaseModel, Field


class ToolResponse(BaseModel):
tool_name: str = Field(
validation_alias="toolName",
serialization_alias="toolName",
)
output: str = Field(
validation_alias="toolOutput",
serialization_alias="toolOutput",
)
error_message: str | None = Field(
default=None,
validation_alias="errorMessage",
serialization_alias="errorMessage",
)

model_config = {"populate_by_name": True}


# Deserialize from camelCase API response
response = ToolResponse.model_validate({
"toolName": "web_search",
"toolOutput": "Found results",
})

# Serialize back to camelCase
api_output = response.model_dump(by_alias=True)
# {'toolName': 'web_search', 'toolOutput': 'Found results', 'errorMessage': None}

Configuration​

model_config​

Customize model behavior with the model_config class attribute:

from pydantic import BaseModel, ConfigDict, Field


class StrictInput(BaseModel):
"""API input model β€” rejects unknown fields."""
query: str
max_results: int = 5

model_config = ConfigDict(
extra="forbid", # Reject unknown fields
str_strip_whitespace=True, # Auto-trim strings
)


# βœ… Valid
input_data = StrictInput(query=" Pydantic ", max_results=10)
print(input_data.query) # "Pydantic" (whitespace stripped)

# ❌ ValidationError β€” unknown field rejected
# input_data = StrictInput(query="test", max_results=5, unknown_field="value")
# β†’ ValidationError: extra fields not permitted

Common Configuration Options​

from pydantic import BaseModel, ConfigDict, Field


class ImmutableConfig(BaseModel):
"""Immutable model β€” fields cannot be changed after creation."""
model_name: str
temperature: float = 0.7

model_config = ConfigDict(
frozen=True, # Immutable β€” no field changes allowed
)


config = ImmutableConfig(model_name="gpt-4o")
# config.temperature = 0.3 # ❌ ValidationError: Instance is frozen


class FlexibleAliasModel(BaseModel):
"""Accept both field name and alias during validation."""
tool_name: str = Field(validation_alias="toolName")

model_config = ConfigDict(
populate_by_name=True, # Accept both 'tool_name' and 'toolName'
)


# Both work:
m1 = FlexibleAliasModel.model_validate({"tool_name": "search"})
m2 = FlexibleAliasModel.model_validate({"toolName": "search"})
Config OptionEffectWhen to Use
extra="forbid"Reject unknown fieldsAPI request bodies β€” catch typos early
extra="ignore"Silently drop unknown fieldsParsing external data with optional extras
extra="allow"Store unknown fields in model_extraFlexible schemas where extras are expected
str_strip_whitespace=TrueTrim leading/trailing whitespace from stringsUser input fields
str_min_length, str_max_lengthGlobal string length constraintsWhen all string fields share the same limits
frozen=TrueMake the model immutableConfiguration objects, cached state
populate_by_name=TrueAccept both field name and aliasAPIs that use camelCase but code uses snake_case
Our Default: extra="forbid"

For all API input models, we use extra="forbid" as the default. It catches typos, schema drift, and unexpected fields at validation time rather than silently accepting them and causing downstream bugs.


Pydantic and Type Hints​

Annotated Types​

Annotated lets you attach metadata (like Field constraints) directly to the type:

from pydantic import BaseModel, Field
from typing import Annotated


PositiveInt = Annotated[int, Field(gt=0)]
BoundedFloat = Annotated[float, Field(ge=0.0, le=1.0)]
NonEmptyStr = Annotated[str, Field(min_length=1)]


class AgentConfig(BaseModel):
max_iterations: PositiveInt
temperature: BoundedFloat = 0.7
system_prompt: NonEmptyStr
tools: list[str] = Field(default_factory=list)


config = AgentConfig(
max_iterations=10,
system_prompt="You are a helpful research assistant.",
)

TypeAdapter​

TypeAdapter validates data that is not a model β€” useful for standalone types:

from pydantic import TypeAdapter, Field, ValidationError
from typing import Annotated


# Validate a list of model names
ModelNameList = TypeAdapter(list[Annotated[str, Field(min_length=1)]])

valid_models = ModelNameList.validate_python(["gpt-4o", "o3-mini"])
print(valid_models) # ['gpt-4o', 'o3-mini']

# Validate a dictionary mapping
from typing import Dict
ScoreMap = TypeAdapter(Dict[str, float])
scores = ScoreMap.validate_python({"relevance": 0.92, "freshness": 0.78})

# Validate and dump
json_output = ModelNameList.dump_json(valid_models)
print(json_output) # b'["gpt-4o","o3-mini"]'

Generic Models​

Use TypeVar to create reusable model templates:

from pydantic import BaseModel, Field
from typing import Generic, TypeVar


T = TypeVar("T")


class ToolResult(BaseModel, Generic[T]):
"""Generic wrapper for tool execution results."""
tool_name: str
success: bool
data: T | None = None
error: str | None = None


# Use with specific types
search_result = ToolResult[list[dict[str, str]]](
tool_name="web_search",
success=True,
data=[{"title": "Pydantic docs", "url": "https://docs.pydantic.dev"}],
)

string_result = ToolResult[str](
tool_name="calculator",
success=False,
error="Division by zero",
)

Computed Fields​

@computed_field derives values from existing fields without storing them:

from pydantic import BaseModel, computed_field, Field


class SearchRequest(BaseModel):
query: str
max_results: int = Field(default=5, ge=1, le=50)
page: int = Field(default=1, ge=1)

@computed_field
@property
def skip_count(self) -> int:
"""Calculate skip offset for pagination."""
return (self.page - 1) * self.max_results

@computed_field
@property
def normalized_query(self) -> str:
"""Return trimmed, lowercased query."""
return self.query.strip().lower()


req = SearchRequest(query=" Python ", max_results=10, page=3)
print(req.skip_count) # 20
print(req.normalized_query) # "python"

# Computed fields appear in model_dump()
print(req.model_dump())
# {'query': ' Python ', 'max_results': 10, 'page': 3,
# 'skip_count': 20, 'normalized_query': 'python'}

Pydantic in AI Workflows​

Validating LLM Tool Call Outputs​

When an LLM calls a tool, the output is a string. Pydantic validates it into a trusted structure:

from pydantic import BaseModel, Field, ValidationError


class SearchToolOutput(BaseModel):
results: list[dict[str, str]] = Field(min_length=0, max_length=20)
total_count: int = Field(ge=0)
query_used: str


def validate_tool_output(raw_output: str) -> SearchToolOutput:
"""Parse and validate LLM tool call output."""
import json

try:
parsed = json.loads(raw_output)
return SearchToolOutput.model_validate(parsed)
except (json.JSONDecodeError, ValidationError) as exc:
raise RuntimeError(f"Tool output validation failed: {exc}") from exc


# Simulated LLM tool output
llm_output = json.dumps({
"results": [
{"title": "Pydantic v2", "url": "https://docs.pydantic.dev"},
],
"total_count": 1,
"query_used": "Pydantic validation",
})

validated = validate_tool_output(llm_output)
print(validated.results[0]["title"]) # "Pydantic v2"

Structuring Agent State​

Agent state in LangGraph is defined as a Pydantic model:

from pydantic import BaseModel, Field
from typing import Literal


class AgentState(BaseModel):
"""State schema for a research agent."""
query: str
plan: list[str] = Field(default_factory=list)
findings: list[str] = Field(default_factory=list)
sources: list[str] = Field(default_factory=list)
current_step: int = 0
status: Literal["planning", "executing", "done", "error"] = "planning"
error_message: str | None = None

model_config = {"extra": "forbid"}


# Initial state
state = AgentState(query="Compare Pydantic v1 vs v2")

# Update state during execution
state.current_step = 1
state.status = "executing"
state.plan = ["Search for Pydantic v2 changelog", "Compare breaking changes"]

# Serialize for checkpointing
checkpoint = state.model_dump_json()

Validating Azure AI Search Results​

When querying Azure AI Search, results come back as dictionaries. Pydantic validates them:

from pydantic import BaseModel, Field
from typing import Annotated


PositiveFloat = Annotated[float, Field(gt=0.0)]


class SearchDocument(BaseModel):
"""Schema for a document returned by Azure AI Search."""
id: str
title: str
content: str
url: str
embedding: list[float] = Field(default_factory=list)
metadata: dict[str, str] = Field(default_factory=dict)


class AzureSearchResponse(BaseModel):
"""Wrapper for the full Azure AI Search response."""
query: str
documents: list[SearchDocument]
total_count: int = Field(ge=0)
search_latency_ms: PositiveFloat


# Simulated Azure AI Search response
raw_response = {
"query": "Python async patterns",
"documents": [
{
"id": "doc-001",
"title": "AsyncIO Guide",
"content": "Python's asyncio module provides...",
"url": "https://docs.python.org/3/library/asyncio.html",
"embedding": [0.1, 0.2, 0.3],
"metadata": {"source": "python.org", "language": "en"},
},
],
"total_count": 1,
"search_latency_ms": 23.5,
}

validated = AzureSearchResponse.model_validate(raw_response)
print(validated.documents[0].title) # "AsyncIO Guide"

Testing Pydantic Models​

Parametrized Validation Tests​

import pytest
from pydantic import BaseModel, Field, ValidationError


class LLMConfig(BaseModel):
model_name: str = Field(min_length=1)
temperature: float = Field(ge=0.0, le=2.0)
max_tokens: int = Field(gt=0, le=128_000)


@pytest.mark.parametrize(
"model_name,temperature,max_tokens,should_fail",
[
("gpt-4o", 0.7, 4096, False),
("o3-mini", 0.0, 1, False),
("claude-sonnet-4", 2.0, 128_000, False),
("", 0.7, 4096, True), # Empty model name
("gpt-4o", -0.1, 4096, True), # Negative temperature
("gpt-4o", 2.1, 4096, True), # Temperature too high
("gpt-4o", 0.7, 0, True), # Zero max_tokens
("gpt-4o", 0.7, 128_001, True), # max_tokens too high
],
)
def test_llm_config_validation(
model_name: str,
temperature: float,
max_tokens: int,
should_fail: bool,
) -> None:
"""LLMConfig validates model parameters correctly."""
kwargs = {
"model_name": model_name,
"temperature": temperature,
"max_tokens": max_tokens,
}

if should_fail:
with pytest.raises(ValidationError):
LLMConfig(**kwargs)
else:
config = LLMConfig(**kwargs)
assert config.model_name == model_name
assert config.temperature == temperature
assert config.max_tokens == max_tokens

Testing Invalid Inputs​

import pytest
from pydantic import BaseModel, ValidationError


class SearchRequest(BaseModel):
query: str
max_results: int


def test_search_request_rejects_invalid_types() -> None:
"""SearchRequest rejects non-string query and non-int max_results."""
with pytest.raises(ValidationError) as exc_info:
SearchRequest(query=123, max_results="five")

errors = exc_info.value.errors()
assert len(errors) == 2
assert errors[0]["loc"] == ("query",)
assert errors[1]["loc"] == ("max_results",)

Snapshot Testing for Serialization​

import pytest
from pydantic import BaseModel, Field


class ToolOutput(BaseModel):
tool_name: str
result: str
is_error: bool = False
metadata: dict[str, str] = Field(default_factory=dict)


def test_tool_output_serialization() -> None:
"""ToolOutput serializes to expected JSON structure."""
output = ToolOutput(
tool_name="web_search",
result="Found 5 results",
is_error=False,
metadata={"query": "test", "source": "internal"},
)

dumped = output.model_dump()
assert dumped == {
"tool_name": "web_search",
"result": "Found 5 results",
"is_error": False,
"metadata": {"query": "test", "source": "internal"},
}

json_str = output.model_dump_json()
assert "web_search" in json_str
assert "Found 5 results" in json_str

Key Takeaways​

Summary
  • Pydantic validates at the boundary β€” use it for API inputs, tool outputs, agent state, and search results. Every untrusted data path goes through a model.
  • BaseModel is your workhorse β€” define fields with type annotations, add constraints with Field(), and let Pydantic handle validation.
  • @field_validator for single fields, @model_validator for cross-field rules β€” custom validation lives here, not in scattered if statements.
  • Serialization is built in β€” .model_dump(), .model_dump_json(), .model_validate() handle Python-to-JSON round-trips.
  • model_config = ConfigDict(extra="forbid") for API inputs β€” reject unknown fields to catch errors early.
  • Use Annotated + Field for reusable type constraints β€” PositiveInt, BoundedFloat, etc. compose cleanly.
  • TypeAdapter validates non-model data β€” lists, dicts, and primitive collections get the same validation rigor.
  • Test validation, not just happy paths β€” parametrized tests for invalid inputs catch schema drift before it reaches production.

Further Reading​