PydanticAI, an agent whose output you can type
PydanticAI came out of the team behind the validation library most of modern Python rests on, and it shows in every part. A model response returns as an object matching a schema, dependencies are injected as in a proper web framework, and testing needs no model call.
What sets this approach apart
Most agent libraries treat a model response as text you then parse. Here the starting point is a type, and the library takes responsibility for making the result fit it.
pip install pydantic-aifrom pydantic import BaseModel
from pydantic_ai import Agent
class Ticket(BaseModel):
category: str
priority: int
needs_human: bool
agent = Agent(
"openai:gpt-5-mini",
output_type=Ticket,
system_prompt="You classify customer tickets. Priority from 1 to 5.",
)
result = agent.run_sync("I cannot log in since yesterday, urgent")
print(result.output.priority)The returned object is already validated, so reading a field needs no check that it exists. If the model answers off schema, the library asks it to correct itself, passing the validation message along, and you see only a valid result or an exception.
That solves the most common integration problem: handling a response that looks fine and refuses to parse. The code that used to deal with regular expressions and edge cases disappears.
Dependencies instead of globals
The second distinguishing element is how context reaches tools. Rather than reaching for globals or closures, you declare a dependency type and the library supplies it on every call.
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
@dataclass
class Deps:
db: DatabaseConnection
customer_id: str
agent = Agent("openai:gpt-5", deps_type=Deps)
@agent.tool
async def order_history(ctx: RunContext[Deps], limit: int = 5) -> list[dict]:
"""Returns recent orders for the current customer.
Call this when the question concerns earlier purchases.
"""
return await ctx.deps.db.orders(ctx.deps.customer_id, limit)
result = await agent.run("What did I buy recently?", deps=Deps(db=db, customer_id="42"))That arrangement has three consequences. The tool does not know where its database connection comes from, so a test substitutes a stub. The customer identifier comes from call context rather than from an argument the model could manipulate. Tool code stays clean, since all configuration sits in one place.
The last point matters more than it looks. An agent serving many users should never let the model decide whose data to fetch. Passing the identifier through dependencies rather than through the tool schema closes that path at the architectural level.
Testing without calling a model
This is where the library delivers most and, at the same time, where people take least advantage. Agent tests usually either do not exist or cost tokens on every run.
from pydantic_ai.models.test import TestModel
def test_classification_returns_priority():
with agent.override(model=TestModel()):
result = agent.run_sync("Payment is not working")
assert 1 <= result.output.priority <= 5The test model generates responses matching the declared schema, so you check the flow, dependency handling, and validation with no network and no cost. To exercise a specific path you can also substitute a model returning a fixed response.
Test tools as ordinary functions separately. Since dependencies are injected, calling a tool in a test needs neither an agent nor a model, only a dependency object holding stubs.
The third layer is quality evaluation over a case set, served by a separate package from the same family. That is measurement rather than a unit test, so run it deliberately, since it consumes tokens.
Observability and durable execution
The library plugs into the standard telemetry protocol, so call traces reach the vendor's own observability tool or any compliant system. You then see tool calls, retries after failed validation, and token usage in one place.
That last number is often the most instructive. An agent correcting an off schema response twice consumes three times the tokens a single call suggests, and without traces that difference stays invisible.
Durable execution is a separate topic, meaning resilience to failure during a long task. The library integrates with workflow engines, so a process interrupted by a network error or a restart resumes from where it stopped rather than starting over.
That matters for multi step tasks where every step costs. Resuming a process that already made seven of ten calls saves time and the money spent on tokens.
Tools, MCP, and toolsets
You define a tool with a decorator, and the argument schema comes from type annotations and the docstring. It is the same mechanism you know from this team's web frameworks, so there is no separate format to learn.
Toolsets let you group functions and attach them to an agent wholesale, which tidies the code once there are ten tools. The library also supports connecting servers in the MCP protocol, so ready made integrations attach without writing your own wrappers.
Deferred tools are a separate category, meaning ones whose execution needs a human decision or a longer process outside the agent. The agent then signals the need for a call, and you fulfil it at your own pace and return with a result. That mechanism is needed wherever an action touches money or customer communication.
Streaming and partial responses
An application with a user interface needs to show something sooner than eight seconds in. The library supports streaming even when the output is typed, which sounds contradictory and works through partial validation.
async with agent.run_stream("Describe outages from the last day") as stream:
async for chunk in stream.stream_output(debounce_by=0.1):
update_view(chunk)Successive versions of the object arrive as the model fills fields, and the library validates whatever has landed so far. The view gets the category first, then the description, rather than a blank screen until generation ends.
The debounce parameter matters in practice. Without it you get an update per token, which when driving an interface can load the browser more heavily than the model call itself. A tenth of a second usually suffices.
Remember separately that streaming does not reduce cost. You pay for the same tokens and gain only perceived responsiveness, but on longer answers that is the difference between an app people use and one they close.
Multi step flows and graphs
A single agent suffices for tasks describable with one tool set. Once a process has distinct stages with branches, the library offers a separate package for building graphs, where each node is a step and transitions are declared through return types.
That approach differs from the popular shared state model. Instead of a dictionary every node writes fields into, you pass objects of concrete classes, and a node's return type states where the process may go next. A wrong connection surfaces during type checking rather than on the third run.
In practice, start with one agent and a few tools, and reach for a graph only once you need to pause the process for a human decision or resume it after a break. Introducing a graph earlier adds code without adding control.
A good signal that a graph is due is the moment your system prompt starts describing step order. Since the order is known, expressing it in code beats hoping the model reconstructs it every time.
Evaluation instead of impressions
A prompt change that subjectively improves answers often breaks another case. The evaluation library from the same family lets you define a case set and measure the score after every change.
from pydantic_evals import Case, Dataset
dataset = Dataset(cases=[
Case(name="login", inputs="I cannot log in", expected_output={"priority": 4}),
Case(name="invoice", inputs="Please resend my invoice", expected_output={"priority": 2}),
])
report = dataset.evaluate_sync(task)Thirty cases are enough to see whether a change helps. Collect them from real traffic, especially from situations where the agent failed, since those are the ones that recur.
Separate two kinds of check. Deterministic evaluation, comparing a field against an expected value, is cheap and fast. Evaluation using a model as judge costs tokens and introduces noise of its own, so reserve it for things no simpler check can cover.
PydanticAI against the alternatives
| Tool | Strength | Weakness | Pick it when |
|---|---|---|---|
| PydanticAI | Typed outputs and dependencies, testing without a model, ecosystem consistency | Smaller catalogue of ready integrations | Production Python application requiring predictability |
| LangChain | Largest integration set, agent in one function | More abstraction to learn | Project combining many models, tools, and sources |
| CrewAI | Legible role model, fast start | Less control over output shape | Agent team with divided tasks |
| LangGraph | Full control over flow, durable state | More work on simple cases | Process with branching and pauses |
The choice depends on what needs guaranteeing. If what matters most is that the result can pass safely into later code, typing wins. If what matters most is the number of ready integrations, the larger ecosystem wins.
These libraries do not exclude each other. An agent built here can use MCP servers exposed by other tools and pass its result into a flow built elsewhere.
Model settings, limits, and a fallback model
A production agent will eventually meet vendor overload or a rate limit. The library lets you declare a fallback model that takes over the call when the primary one errors, without touching the rest of the code.
The same mechanism sometimes serves as a saving. A cheaper model handles the simple case, and the expensive one steps in only when the cheaper one cannot satisfy the schema. Measure whether that arrangement actually saves, though, since two calls instead of one can cost more than reaching for the stronger model straight away.
Model settings, among them the response token cap and the timeout, are declared once on the agent or overridden per run. The timeout is the one most often forgotten, and it decides whether a hung call blocks the process for a quarter of an hour.
Set run level limits separately: a maximum number of model calls and a maximum number of tool calls. An agent looping and calling the same tool forty times is not a theoretical situation, and without a cap you will meet it on the invoice.
When not to reach for it
For a single model call where you only want text, this library is surplus. The vendor client suffices and adds fewer dependencies.
The same goes for a prototype whose purpose is checking whether the model can handle the task at all. Typing helps when the result feeds code, not when you read it yourself.
The third case is a team working mainly in TypeScript. The library is Python only, so integrating through a separate service adds a layer better avoided when the rest of the system lives in another language.
The boundary is fairly legible. If a model's answer is going into a function that will do something with it, typing pays from day one. If a person reads the answer, the gain is slight.
Common mistakes
The first is an over elaborate output schema. A model filling a structure with fifteen fields and nested lists hits validation more often, and every correction costs. Start with three fields and expand.
The second is passing a user identifier in the tool schema rather than in dependencies. The model can then supply somebody else's identifier, and you have no safeguard beyond watching the prompt.
The third is no retry limit on validation. A model stubbornly returning an off schema result will retry as many times as you allow, so set a cap and treat hitting it as a signal to fix the schema.
The fourth is docstrings written for a developer. The model reads the same text, so a sentence saying when to call the tool matters more than a description of the implementation.
The fifth is skipping tests because an agent is nondeterministic anyway. The test model lets you check everything except the response text itself, and that is where most bugs hide.
FAQ
Is PydanticAI free?
Yes, the library is open source under the MIT licence and free commercially. You pay only for model calls at your chosen vendor. The maintainers run a separate observability tool with a free plan covering ten million records a month, capped once that limit is reached, and paid plans from 49 dollars a month. The library works without it and emits telemetry to any compliant system.
PydanticAI or LangChain?
Choose PydanticAI when you want a predictable output shape, testability, and consistency with the rest of your Python code. LangChain carries far more ready integrations and fits better when you need to connect many data sources without writing adapters.
Does it work with models other than OpenAI?
Yes, the model layer is vendor neutral and supports Claude, Gemini, and local models served by Ollama, among others. Switching comes down to changing the model identifier, though the prompt usually needs retuning.
What does typed output buy in practice?
The code layer handling parsing and mismatched responses disappears. A result returns as a schema conforming object or does not return at all, so later steps can rely on it without checking. For classification and data extraction that is the largest saving.
Is it production ready?
Yes, under two conditions. Set retry limits and error handling, since without them failed validation turns into a loop burning tokens. Connect telemetry too, because without call traces, diagnosing agent behaviour comes down to guessing.
Documentation sits on the project site, and the source code in the GitHub repository.