
Pydantic AI: The Production Guide (Past Hello World)
Raw LLM outputs break apps. You ask for JSON, you get markdown. You ask for a number between 1 and 10, you get "Sure! Here's a number: seven." If you've built anything real with LLM APIs, you've written defensive parsing code that makes you question your career choices. Pydantic AI fixes this, it's the type-safe agent framework built by the same team behind Pydantic and FastAPI. Think of it as "FastAPI for AI agents": you define what you want with Python type hints, and the framework handles validation, retries, and tool calling.
This Pydantic AI guide is for developers who've already run their first LLM call and want production patterns: structured outputs that don't break, dependency injection for testable agents, and real-world tools beyond weather APIs. By the end, you'll have working agents with tools, DI, streaming, and tests.
<!-- IMAGE: Pydantic AI agent architecture, Agent receives prompt, calls tools via RunContext, validates output through Pydantic model -->Pydantic AI at a Glance
| Attribute | Details |
|---|---|
| What it is | Type-safe AI agent framework for Python |
| Built by | Pydantic team (Samuel Colvin et al.) |
| Philosophy | "FastAPI for AI agents", type hints drive everything |
| License | MIT (open-source) |
| Current Version | v1.74.0 (March 2026) |
| Python Version | 3.9+ |
| Supported Models | OpenAI, Anthropic, Google Gemini, Groq, Mistral, Ollama, and more |
| Key Features | Structured outputs, tool calling, dependency injection, streaming, TestModel |
| GitHub Stars | 16,000+ |
| Production Ready | Yes, v1.0 released September 2025 |
| Observability | Native Logfire integration (OpenTelemetry-based) |
| Learning Curve | Low if you know Pydantic/FastAPI; moderate otherwise |
The standout features are structured outputs (validated with Pydantic models), dependency injection (like FastAPI's Depends), and TestModel (mock LLM for testing without API calls). If you're coming from LangChain and wondering "is there something cleaner?", this is probably it.
Installation and First Agent
# Install with OpenAI support (swap openai for anthropic, google, etc.)
pip install "pydantic-ai[openai]"
# Set your API key
export OPENAI_API_KEY="sk-..."Your first agent in 5 lines:
from pydantic_ai import Agent
agent = Agent("openai:gpt-4o", system_prompt="You are a helpful assistant.")
result = agent.run_sync("What's the capital of France?")
print(result.output) # "Paris"That's it. Agent wraps the model, run_sync sends a prompt and returns a result. The result.output is a plain string here, but that's about to change.
Structured Outputs, Why Pydantic AI Exists
This is the core feature. Instead of getting a string back from the LLM and hoping it's valid JSON, you define a Pydantic model and the agent returns a validated Python object.
Before: Raw LLM Output
# The old way -- hope for the best
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Review the movie Inception. Return JSON with title, rating (1-10), summary."}]
)
# response.choices[0].message.content is a string
# Maybe it's JSON. Maybe it has markdown code fences. Maybe rating is "eight".
# You're on your own.After: Structured with Pydantic AI
from pydantic import BaseModel
from pydantic_ai import Agent
class MovieReview(BaseModel):
title: str
rating: int # Guaranteed to be an int, not "eight"
summary: str
recommended: bool
agent = Agent("openai:gpt-4o", result_type=MovieReview)
result = agent.run_sync("Review the movie Inception")
review = result.output # This is a MovieReview instance, not a string
print(f"{review.title}: {review.rating}/10")
print(f"Recommended: {review.recommended}")
print(review.summary)The difference is night and day. result.output is a real MovieReview object. If the LLM returns rating: "eight" instead of rating: 8, Pydantic's validation catches it. For a deeper look at how this works across different providers, see our guide on structured outputs across LLM providers.
What Happens When Validation Fails
Here's the part no other tutorial shows: what happens when the LLM messes up?
from pydantic import BaseModel, Field
from pydantic_ai import Agent
class StrictReview(BaseModel):
title: str
rating: int = Field(ge=1, le=10) # Must be 1-10
pros: list[str] = Field(min_length=2) # At least 2 pros
agent = Agent("openai:gpt-4o", result_type=StrictReview)
# If the LLM returns rating=15 or only 1 pro:
# 1. Pydantic validation fails
# 2. The error message is sent BACK to the LLM
# 3. The LLM tries again with the corrected output
# 4. This repeats up to the retry limit
result = agent.run_sync("Review the movie Inception")This retry-with-feedback loop is Pydantic AI's killer feature. The LLM learns from its own validation errors. You don't write retry logic, the framework handles it.
Verdict: Structured outputs are the single best reason to use Pydantic AI over raw API calls. If you're parsing LLM JSON by hand, stop.
Tools and Function Calling
Tools let your agent call Python functions to get real data. Instead of the LLM hallucinating facts, it can query your database, search your docs, or call an API.
Registering a Tool
from pydantic_ai import Agent
agent = Agent("openai:gpt-4o")
@agent.tool
async def search_docs(query: str) -> str:
"""Search the documentation for relevant articles."""
# Your actual search logic here
results = await doc_search_engine.search(query, limit=5)
return "\n".join(r.title + ": " + r.snippet for r in results)The @agent.tool decorator registers the function. Pydantic AI reads the function's type hints and docstring to tell the LLM what the tool does, what arguments it takes, and what it returns. No manual schema writing, your type hints ARE the schema. For background on how LLM function calling works under the hood, we have a dedicated guide.
RunContext: Passing Data to Tools
Here's where Pydantic AI diverges from other frameworks. RunContext lets you pass runtime data (database connections, user info, API clients) to your tools without global state.
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
@dataclass
class SupportDeps:
customer_id: str
db_connection: DatabaseConnection
agent = Agent("openai:gpt-4o", deps_type=SupportDeps)
@agent.tool
async def get_order_history(ctx: RunContext[SupportDeps], limit: int = 5) -> str:
"""Fetch recent orders for the current customer."""
orders = await ctx.deps.db_connection.query(
"SELECT * FROM orders WHERE customer_id = $1 ORDER BY date DESC LIMIT $2",
ctx.deps.customer_id, limit
)
return format_orders(orders)The ctx.deps gives the tool access to whatever you passed at runtime. The tool doesn't import a global database connection, it receives one. This is dependency injection, and it's what makes your agents testable.
A Real-World Tool Example
@agent.tool
async def run_sql_query(ctx: RunContext[SupportDeps], sql: str) -> str:
"""Run a read-only SQL query against the analytics database.
Only SELECT queries are allowed."""
if not sql.strip().upper().startswith("SELECT"):
return "Error: only SELECT queries are allowed"
results = await ctx.deps.db_connection.fetch(sql)
return json.dumps(results, default=str)Verdict: Tool calling in Pydantic AI is cleaner than any other framework thanks to type hints doing the heavy lifting. You write normal Python functions with type annotations. The framework figures out the rest.
Dependency Injection, The Feature LangChain Wishes It Had
If you've used FastAPI's Depends, you already understand Pydantic AI's DI system. If you haven't, here's the short version: instead of your agent reaching out to grab what it needs (global database connections, API clients, config), you hand it everything at runtime.
Defining Dependencies
from dataclasses import dataclass
from pydantic_ai import Agent
@dataclass
class AppDeps:
db: AsyncDatabasePool
search_client: SearchAPIClient
current_user: User
agent = Agent(
"openai:gpt-4o",
deps_type=AppDeps,
system_prompt="You are a customer support agent."
)Using Dependencies in Tools
@agent.tool
async def lookup_account(ctx: RunContext[AppDeps]) -> str:
"""Look up the current user's account details."""
account = await ctx.deps.db.fetchrow(
"SELECT * FROM accounts WHERE user_id = $1",
ctx.deps.current_user.id
)
return json.dumps(account, default=str)
# Run with real dependencies
result = await agent.run(
"What's my account status?",
deps=AppDeps(db=real_db, search_client=real_search, current_user=user)
)Why DI Makes Your Agents Testable
This is the real payoff. In LangChain, you'd pass context through chain kwargs or closures, there's no standard pattern. In Pydantic AI, swapping real dependencies for test doubles is trivial:
# In your test file
from pydantic_ai import Agent
from your_app import agent, AppDeps
async def test_account_lookup():
mock_deps = AppDeps(
db=MockDatabase({"user_123": {"status": "active", "plan": "pro"}}),
search_client=MockSearch(),
current_user=User(id="user_123")
)
result = await agent.run("What's my account status?", deps=mock_deps)
assert "active" in result.output
assert "pro" in result.outputNo monkey-patching. No mocking global imports. You just pass different deps.
Verdict: Dependency injection is why experienced Python developers prefer Pydantic AI. It's the FastAPI influence showing.
Model Providers, OpenAI, Anthropic, Gemini, Ollama
Pydantic AI is model-agnostic. Switching providers is a one-line change:
# OpenAI
agent = Agent("openai:gpt-4o")
# Anthropic
agent = Agent("anthropic:claude-sonnet-4-20250514")
# Google Gemini
agent = Agent("google-gla:gemini-2.0-flash")
# Local Ollama
agent = Agent("ollama:llama3.1")Everything else, tools, structured outputs, DI, stays identical. Your business logic doesn't change when you switch models.
| Provider | Models | Free Tier | Setup Complexity |
|---|---|---|---|
| OpenAI | GPT-4o, GPT-4o mini, o1 | $5 credit (new accounts) | Low, API key only |
| Anthropic | Claude Sonnet, Haiku, Opus | No free tier | Low, API key only |
| Google Gemini | Gemini 2.0 Flash, Pro | Generous free tier | Medium, project setup |
| Groq | Llama, Mixtral | Free tier available | Low, API key only |
| Ollama (local) | Llama, Mistral, Phi, etc. | Completely free | Medium, install Ollama |
Verdict: Model-agnostic design means you're never locked into one provider. Start with OpenAI for convenience, benchmark with Anthropic, and use Ollama for local development.
Streaming Responses
For chat UIs and real-time applications, streaming is essential. Pydantic AI supports it while maintaining type safety:
from pydantic_ai import Agent
from pydantic import BaseModel
class AnalysisResult(BaseModel):
summary: str
sentiment: str
confidence: float
agent = Agent("openai:gpt-4o", result_type=AnalysisResult)
async def stream_analysis(text: str):
async with agent.run_stream(f"Analyze this text: {text}") as stream:
async for partial in stream.stream_structured():
# partial is a partially-validated AnalysisResult
print(f"Streaming: {partial}")
# Final result is fully validated
result = await stream.get_output()
print(f"Final: {result.summary} ({result.confidence:.0%} confident)")This works beautifully with FastAPI's StreamingResponse, same ecosystem, same patterns. The Pydantic AI agents docs cover advanced streaming options including text-only streaming with stream_text().
Pydantic AI vs LangGraph vs OpenAI Agents SDK
You're here, so you're probably asking: "should I use Pydantic AI or LangGraph?" Honest answer: they solve different problems, and you might use both.
Feature Comparison Table
| Feature | Pydantic AI | LangGraph | OpenAI Agents SDK |
|---|---|---|---|
| Type Safety | Full (Pydantic models) | Partial (TypedDict) | Minimal |
| Dependency Injection | Built-in (FastAPI-style) | None | None |
| Structured Outputs | Native with retry | Via output parsers | Via JSON mode |
| Tool Calling | @agent.tool decorator | @tool decorator | function definitions |
| Multi-Agent | Basic handoffs | Advanced (state machines) | Handoffs + guardrails |
| Streaming | Typed streaming | Streaming events | Streaming |
| Model Support | 10+ providers | Primarily LangChain models | OpenAI only |
| Testing | TestModel built-in | No built-in testing | No built-in testing |
| Learning Curve | Low (if you know Pydantic) | High (graph concepts) | Low (simple API) |
| Community Size | Growing (16K stars) | Large (LangChain ecosystem) | Growing (OpenAI backing) |
| Best For | Clean, testable agents | Complex state workflows | OpenAI-only projects |
When to Use Each
Choose Pydantic AI when you want clean, type-safe agent code. It's ideal for single-agent tasks with tools (customer support bots, data extraction, code review agents) and situations where testability matters. If your team already uses FastAPI and Pydantic, the learning curve is almost flat.
Choose LangGraph when you need complex multi-step workflows with conditional branching, human-in-the-loop approval, and sophisticated state management. LangGraph excels at orchestrating multiple steps, not individual agent quality. For a deep dive, see our full LangGraph vs CrewAI vs OpenAI Agents SDK comparison.
Choose OpenAI Agents SDK when you're 100% on OpenAI, want the simplest possible setup, and don't need multi-provider support or DI.
The Combination Pattern
Here's what experienced teams actually do: use Pydantic AI for individual agents (clean code, testable, typed outputs) and LangGraph for orchestration between agents (routing, state machines, conditional logic). They're not competing, they're complementary layers.
# Pydantic AI agent -- clean, testable, type-safe
support_agent = Agent("openai:gpt-4o", result_type=SupportResponse, deps_type=SupportDeps)
# LangGraph graph -- orchestrates when to call which agent
graph = StateGraph(SupportState)
graph.add_node("classify", classify_intent)
graph.add_node("support", lambda state: support_agent.run_sync(state["query"]))
graph.add_node("escalate", escalate_to_human)Verdict: Choose Pydantic AI for clean, testable agent code. Choose LangGraph for complex multi-step workflows. They're not mutually exclusive.
Testing Your Agents with TestModel
This is the section that separates a beginner guide from a production guide. Every real codebase needs tests, and testing agents is notoriously hard, LLM calls are slow, expensive, and non-deterministic. Pydantic AI ships a solution: TestModel.
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
from pydantic import BaseModel
class SupportResponse(BaseModel):
answer: str
confidence: float
escalate: bool
agent = Agent("openai:gpt-4o", result_type=SupportResponse)
# In tests: swap the real model for TestModel
def test_support_agent():
with agent.override(model=TestModel()):
result = agent.run_sync("I need help with billing")
# TestModel returns valid structured data matching your result_type
assert isinstance(result.output, SupportResponse)
assert isinstance(result.output.confidence, float)
assert isinstance(result.output.escalate, bool)TestModel generates valid data that matches your result_type without making any API calls. Zero cost, deterministic, fast. The Pydantic AI testing docs cover advanced patterns like FunctionModel for custom responses and capture_run_messages for inspecting tool calls.
Testing Tools and DI Together
def test_order_lookup_tool():
# Mock dependencies
mock_deps = SupportDeps(
customer_id="test-123",
db_connection=MockDB(orders=[{"id": "ord-1", "status": "shipped"}])
)
with agent.override(model=TestModel()):
result = agent.run_sync(
"Where is my order?",
deps=mock_deps
)
assert isinstance(result.output, SupportResponse)No API calls. No flaky tests. No cost. Run this in CI/CD alongside the rest of your test suite.
This is the #1 content gap on the entire SERP. No other Pydantic AI guide covers testing. If you're building agents for production, this is what you need.
Observability, Logfire Integration in 5 Minutes
Production agents need AI observability. You want to see every LLM call, tool invocation, latency, token count, and cost. Pydantic AI integrates natively with Logfire, the Pydantic team's observability platform (built on OpenTelemetry).
import logfire
from pydantic_ai import Agent
logfire.configure() # Uses LOGFIRE_TOKEN env var
logfire.instrument_pydantic_ai()
agent = Agent("openai:gpt-4o", result_type=MovieReview)
# Every run is now traced automatically
result = agent.run_sync("Review Inception")Three lines. You get full traces showing: prompt sent, model response, tool calls (if any), validation passes/failures, retries, latency, and estimated cost. If Logfire isn't your thing, Langfuse is a solid open-source alternative with context engineering support for tracing how your prompts evolve.
FAQ
What is Pydantic AI and how is it different from LangChain?
Pydantic AI is a type-safe agent framework where Python type hints drive validation, tool schemas, and dependency injection. LangChain is a larger framework focused on chaining LLM calls together. The key difference: Pydantic AI validates outputs at the framework level and provides built-in dependency injection for testability, LangChain does neither by default.
How do I build a type-safe AI agent with Pydantic AI?
Define a Pydantic BaseModel for your output, pass it as result_type to Agent, and call run_sync() or run(). The agent returns a validated instance of your model, not a raw string. See the Structured Outputs section for complete examples.
Should I use Pydantic AI or LangGraph for production agents?
Use Pydantic AI for individual agents where type safety, testability, and clean code matter. Use LangGraph for orchestrating complex multi-step workflows with conditional routing. Many teams use both, Pydantic AI agents inside a LangGraph orchestration layer.
How does Pydantic AI handle tool calling and dependency injection?
Decorate a function with @agent.tool and Pydantic AI reads its type hints to generate the tool schema. For DI, set deps_type on the Agent and accept RunContext[YourDeps] in tools. Runtime dependencies (DB connections, API clients) flow through without global state.
How do I add streaming to a Pydantic AI agent?
Use agent.run_stream() instead of agent.run(). It returns an async context manager that yields partial results via stream_structured() or stream_text(). The final result is still fully validated against your result_type.
Is Pydantic AI production-ready in 2026?
Yes. Version 1.0 shipped in September 2025 with an API stability commitment. It's backed by the Pydantic team (the most-downloaded Python library for data validation) and currently at v1.74.0 with regular updates.
Can I use Pydantic AI with Ollama and local models?
Yes. Use Agent("ollama:llama3.1") and make sure Ollama is running locally. Install the ollama provider extra: pip install "pydantic-ai[ollama]". Structured outputs and tools work the same as with cloud providers.
How do I test Pydantic AI agents?
Use TestModel, a mock model that generates valid structured data matching your result_type without API calls. Wrap your test in agent.override(model=TestModel()) and run assertions on the output. See the Testing section for complete pytest examples.
Does Pydantic AI work with FastAPI?
Perfectly. They share the same dependency injection philosophy and are built by the same team. You can use Pydantic AI agents inside FastAPI endpoints, share dependency types between them, and stream agent responses through StreamingResponse.
What's the difference between Pydantic AI and the OpenAI Agents SDK?
Pydantic AI is model-agnostic (works with OpenAI, Anthropic, Gemini, Ollama, etc.), has dependency injection, TestModel for testing, and Pydantic validation. The OpenAI Agents SDK is simpler but locked to OpenAI models and lacks DI and built-in testing. Choose Pydantic AI for flexibility; choose OpenAI Agents SDK for the simplest possible OpenAI-only setup.
Key Takeaways and Next Steps
| Concept | Key Insight | Next Step |
|---|---|---|
| Structured Outputs | Your result_type is validated and retried automatically | Define Pydantic models for all agent outputs |
| Tools | Type hints ARE the schema, no manual definitions | Build tools with @agent.tool and RunContext |
| Dependency Injection | Pass runtime deps explicitly for testability | Define a deps_type dataclass for every agent |
| Testing | TestModel eliminates API costs in CI/CD | Add agent.override(model=TestModel()) to your test suite |
| Model Providers | One-line model switching, no code changes | Start with OpenAI, benchmark alternatives later |
| Observability | 3-line Logfire setup for full traces | Add logfire.instrument_pydantic_ai() to production |
Start with a small agent that has structured outputs. Add a tool. Add dependencies. Write a test with TestModel. That's the production path, and you now have everything you need to walk it.
The official Pydantic AI docs and GitHub repository are excellent for going deeper. The framework moves fast, so bookmark the changelog.