Pydantic AI: A Hands-On Tutorial
Pydantic AI is a Python agent framework, built by the team behind the Pydantic validation library, that lets you build LLM-powered applications with the same type safety you already rely on for your data models. If you have ever written an agent that returns a plain string and then spent an hour writing regex to pull a JSON object out of it, Pydantic AI removes that entire problem: you declare a Pydantic model as your output type, and the agent guarantees a validated instance of that model comes back, or a clear error if it does not. This tutorial walks through installing Pydantic AI, building an agent with structured output, adding tools, wiring in dependency injection, streaming responses, and writing tests, with runnable code at every step.
What Is Pydantic AI and Why It Matters
Most LLM frameworks treat structured output as an afterthought bolted on with prompt engineering ("please respond in JSON"). Pydantic AI treats it as the foundation. Under the hood, it uses the model provider's native tool-calling or structured-output feature to force the response into your schema, then validates it through Pydantic before your code ever sees it. That means:
- Your IDE autocompletes fields on the agent's output because it is a real Python class, not a dict.
- Invalid responses trigger automatic retries with the validation error fed back to the model, so the model can self-correct.
- You get the same
Field, validators, and nested-model machinery you already use in FastAPI or any other Pydantic-based codebase.
Pydantic AI is also provider-agnostic. The same agent code runs against OpenAI, Anthropic, Google, Groq, Mistral, or a local model server, because the framework abstracts the provider behind a model string like "openai:gpt-4o" or "anthropic:claude-sonnet". Swapping providers is a one-line change, not a rewrite.
Installing Pydantic AI and Setting Up Your First Agent
Install the package with pip, uv, or poetry:
pip install pydantic-aiYou will also need an API key for whichever model provider you use. Set it as an environment variable so the SDK picks it up automatically:
export OPENAI_API_KEY="your-key-here"Here is the smallest possible Pydantic AI agent:
from pydantic_ai import Agent
agent = Agent(
"openai:gpt-4o",
system_prompt="You are a concise assistant that answers in plain English.",
)
result = agent.run_sync("What is the boiling point of water at sea level in Celsius?")
print(result.output)run_sync blocks until the model responds and returns a RunResult object. The .output attribute holds the final answer. For async codebases (FastAPI, most web backends), use await agent.run(...) instead of run_sync.
Notice there is no client setup, no manual request formatting, no manual parsing. The Agent object owns the conversation loop, and you only care about the prompt and the result.
Defining Structured Output with Pydantic Models
The real value shows up when you stop accepting free text and start requiring a schema. Suppose you are building a tool that extracts structured event details from a user's message.
from pydantic import BaseModel, Field
from pydantic_ai import Agent
class EventDetails(BaseModel):
title: str = Field(description="Short name of the event")
date: str = Field(description="ISO 8601 date, e.g. 2026-08-14")
location: str | None = Field(default=None, description="City or venue if mentioned")
attendee_count: int = Field(description="Expected number of attendees, estimate if unclear")
event_agent = Agent(
"openai:gpt-4o",
output_type=EventDetails,
system_prompt="Extract structured event details from the user's message.",
)
result = event_agent.run_sync(
"We're planning a product launch in Austin on March 3rd, expecting around 150 people."
)
event = result.output
print(event.title, event.date, event.location, event.attendee_count)
print(type(event))event here is a real EventDetails instance, not a dictionary you have to trust. If the model returns a date in the wrong format or omits a required field, Pydantic AI automatically retries the call with the validation error appended to the prompt, giving the model a chance to fix its own mistake before the error ever reaches your code. You control the retry budget with the retries argument on Agent or on individual tools.
This pattern scales to nested models too. If you need a list of line items inside an invoice, or a list of steps inside a recipe, just nest BaseModel classes and Pydantic AI handles the schema generation and validation recursively.
class LineItem(BaseModel):
description: str
quantity: int
unit_price: float
class Invoice(BaseModel):
vendor: str
items: list[LineItem]
total: floatAdding Tools to Your Agent
Structured output solves extraction and formatting. Tools solve action: letting the model call your Python functions to fetch data, run calculations, or hit an API. Register a tool with the @agent.tool decorator.
from pydantic_ai import Agent, RunContext
weather_agent = Agent(
"openai:gpt-4o",
system_prompt="Answer questions about the weather using the get_weather tool when needed.",
)
@weather_agent.tool
def get_weather(ctx: RunContext[None], city: str) -> str:
"""Return the current weather for a given city."""
# In a real app this would call a weather API
fake_data = {"Austin": "31C, clear skies", "Seattle": "16C, light rain"}
return fake_data.get(city, "No data available for that city")
result = weather_agent.run_sync("Should I bring an umbrella in Seattle today?")
print(result.output)A few things matter here:
- The docstring on
get_weatherbecomes part of the tool description the model sees, so write it like documentation, not a comment. - Type hints on the function parameters (
city: str) become the tool's JSON schema automatically. No manual schema authoring. RunContextis the first parameter and gives the tool access to dependencies, retry count, and the current message history, even if the tool itself does not use any of them.
You can register as many tools as the task needs. The agent decides, on each turn, whether to call a tool, call multiple tools, or answer directly, based on the conversation and the tool descriptions. If you want a tool available only for certain calls rather than the whole agent's lifetime, pass it through the tools argument at run_sync time instead of the decorator.
Dependency Injection in Pydantic AI
Real tools rarely work with hardcoded dictionaries. They need a database connection, an HTTP client, or a user ID scoped to the current request. Pydantic AI solves this with a typed dependency system instead of global state.
from dataclasses import dataclass
import httpx
from pydantic_ai import Agent, RunContext
@dataclass
class AppDeps:
http_client: httpx.AsyncClient
api_key: str
support_agent = Agent(
"openai:gpt-4o",
deps_type=AppDeps,
system_prompt="Help users check their order status using the lookup_order tool.",
)
@support_agent.tool
async def lookup_order(ctx: RunContext[AppDeps], order_id: str) -> str:
"""Look up an order's shipping status by order ID."""
response = await ctx.deps.http_client.get(
f"https://api.example.com/orders/{order_id}",
headers={"Authorization": f"Bearer {ctx.deps.api_key}"},
)
data = response.json()
return f"Order {order_id} is {data['status']}"
async def main():
async with httpx.AsyncClient() as client:
deps = AppDeps(http_client=client, api_key="secret-key")
result = await support_agent.run("Where is order 48213?", deps=deps)
print(result.output)deps_type declares the shape of dependencies the agent expects. ctx.deps inside any tool gets full type checking against that dataclass, so if you typo a field name, your type checker catches it before the agent ever runs. This is the piece most LLM frameworks skip, and it is the piece that makes Pydantic AI feel like normal application code instead of a notebook script glued onto a web server. You inject a test double for AppDeps in unit tests and a real HTTP client in production, with zero changes to the agent or tool logic.
Streaming Responses
For chat interfaces, waiting for the full response before showing anything feels slow. Pydantic AI supports streaming through run_stream.
async def stream_answer():
async with weather_agent.run_stream("Give me a three-day outlook for Austin") as response:
async for chunk in response.stream_text(delta=True):
print(chunk, end="", flush=True)delta=True yields only the newly generated text since the last chunk, which is what you want for typewriter-style UI rendering. If your output type is a structured Pydantic model rather than plain text, you can stream partial, progressively-validated objects with response.stream_structured(), useful for showing a form fill in as fields arrive rather than waiting for the whole object.
Testing and Evaluating Pydantic AI Agents
Because tools are plain Python functions and dependencies are plain dataclasses, testing an agent does not require mocking an HTTP layer for the LLM itself in most cases, you mock at the dependency boundary. Pydantic AI also ships a TestModel you can swap in for the real model, which returns deterministic, schema-valid responses without making a network call.
from pydantic_ai.models.test import TestModel
def test_event_extraction():
test_agent = event_agent.override(model=TestModel())
result = test_agent.run_sync("Team offsite in Denver next Tuesday, 40 people")
assert isinstance(result.output, EventDetails)TestModel fabricates a value for every field in your output schema, so you can assert on the shape of the response and the fact that your tools got called correctly, without burning API credits on every CI run. For tests that need to check specific model behavior (does the agent call the right tool for this input, does the system prompt actually change the output), use FunctionModel to substitute a small Python function that mimics the model's decision logic, or run against the real model in a separate, slower, tagged test suite.
For ongoing evaluation once the agent is live, log every RunResult (including result.usage() for token counts and result.all_messages() for the full turn-by-turn history) to a table you can review, then build a small eval set of representative prompts with expected output ranges. Re-run that eval set whenever you change the system prompt, swap models, or add a tool, so regressions show up before users hit them.
Using Pydantic AI with Multiple Model Providers
Because the model is specified as a string, switching providers for the same agent is trivial:
from pydantic_ai import Agent
agent_openai = Agent("openai:gpt-4o", output_type=EventDetails)
agent_anthropic = Agent("anthropic:claude-sonnet", output_type=EventDetails)
agent_local = Agent("ollama:llama3", output_type=EventDetails)This matters in practice for two reasons. First, cost and latency tradeoffs: you might run extraction tasks on a cheaper, faster model and reserve a stronger model for the tool-calling agent that needs to reason across multiple steps. Second, resilience: if you wrap agent creation behind a config value or environment variable, you can fail over to a second provider without touching application code, which matters the day a provider has an outage during a launch.
You can also mix models within a single multi-agent pipeline. A common pattern is a fast, cheap model doing intent classification, handing off to a stronger model for the actual generation, with a Pydantic model as the typed contract passed between the two agents. Since both agents speak the same BaseModel schema for handoff, the pipeline stays type-checked end to end even though two different providers are involved.
Common Patterns and Best Practices
A few practices consistently make Pydantic AI agents easier to maintain:
- Keep output models narrow. A
BaseModelwith 3-5 well-described fields validates more reliably than one with 20 optional fields. If you need more data, split it into multiple agent calls or nested models. - Write tool docstrings as if a new engineer will read only the docstring. The model uses that text to decide when to call the tool, so vague docstrings produce inconsistent tool use.
- Use `Field(description=...)` liberally. Field descriptions get included in the schema sent to the model and materially improve extraction accuracy, especially for ambiguous fields like dates or enums.
- Set explicit retry limits. The default retry behavior is helpful, but an agent stuck retrying against a genuinely impossible extraction wastes tokens. Cap retries with the
retriesparameter and handle the final failure explicitly in your application code. - Separate deps from secrets where possible. Pass API keys and clients through
deps_type, not hardcoded in the system prompt or tool body, so you can rotate credentials and swap test doubles without editing agent logic. - Log `result.all_messages()` during development. Seeing the full message history, including tool calls and their raw arguments, is the fastest way to debug why an agent chose (or didn't choose) a tool.
FAQ
What is Pydantic AI used for? Pydantic AI is used to build LLM-powered applications, chatbots, extraction pipelines, and autonomous agents in Python where you need the model's output validated against a strict schema, plus the ability to give the model callable tools and typed dependencies, rather than working with raw text completions.
Is Pydantic AI the same as LangChain? No. Both are Python agent frameworks, but Pydantic AI is built around Pydantic's validation model as the core contract for output and tool arguments, with a smaller surface area and fewer abstractions layered between your code and the underlying model call. LangChain has a broader ecosystem of prebuilt integrations; Pydantic AI focuses on type safety and a leaner core.
Does Pydantic AI work with local models? Yes. It supports OpenAI-compatible local servers and providers like Ollama through the same model-string interface used for hosted providers, so you can develop against a local model and deploy against a hosted one with a one-line change.
How does Pydantic AI handle invalid model output? When a model returns output that fails validation against your output_type, Pydantic AI automatically retries the request, feeding the validation error back to the model so it can correct its own response. You control the maximum number of retries per agent or per tool.
Can I use Pydantic AI in a FastAPI backend? Yes, and it is a natural fit since both libraries share the same underlying Pydantic models. Call await agent.run(...) inside your async route handlers, pass request-scoped dependencies (database sessions, HTTP clients) through deps_type, and return result.output directly since it is already a validated Pydantic model that FastAPI can serialize.
Does Pydantic AI support multi-agent workflows? Yes. You can call one agent from inside another agent's tool, pass typed Pydantic models as the handoff contract between agents, and mix different underlying model providers across the agents in the same pipeline.
How do I test agents without calling a real model? Use TestModel to get deterministic, schema-valid responses without a network call, or FunctionModel to substitute custom logic that simulates specific model decisions, both included in the pydantic_ai.models.test module.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.