Pydantic AI is a Python-native agent framework built around type safety: you define the exact shape of the answer you want as a Pydantic model, and the agent validates its own output against it before returning. This guide builds a travel-itinerary agent with two tools (flight and hotel search) and dependency-injected API clients via RunContext, returning a structured Itinerary object instead of a wall of text. Budget 40-50 minutes and a few cents per run on Claude Sonnet 5.5.
| Fact | Value |
|---|---|
| Tool | Python |
| Difficulty | Intermediate |
| Time required | 40-50 min |
| Compatibility | Python 3.10+, pydantic-ai 2.x, Claude or OpenAI API key, any OS |
| Last updated | November 2026 |
What you'll build
You'll build a travel-itinerary agent: tell it a destination, origin, and number of days, and it searches flights and hotels, then returns a day-by-day plan as a validated Python object, not a paragraph you have to parse yourself. Ask for Itinerary.days[1].estimated_cost_usd right after the call returns; there's no regex or JSON.loads involved.
That's the specific thing Pydantic AI buys you over a framework-agnostic approach: output_type takes a Pydantic model, and the agent keeps retrying internally until its answer actually validates against it. Tools work the same way in reverse. Every @agent.tool function takes a RunContext[Deps] as its first argument, and ctx.deps carries whatever dependencies you injected at call time (an API client, a database connection, an API key), so a tool never reaches into a global variable or a closure to get what it needs.
Pick Pydantic AI when you're building in Python specifically and want the agent's output typed and validated like the rest of your codebase. Pick LangChain's create_agent (see the pillar guide) when you want the widest ecosystem of middleware and examples. Pick the from-scratch approach when you need to see every request. All three run the same kind of tool-calling loop underneath; this guide is about what changes when Python's own type system does some of the work for you.
Prerequisites
- Python 3.10+ and a terminal
- An Anthropic or OpenAI API key with available credits
- pip install pydantic-ai
- Comfort with dataclasses, type hints, and Pydantic models
Step-by-step guide
Install Pydantic AI and set your API key
Install pydantic-ai, which pulls in Pydantic itself as a dependency. Export your model provider's API key as an environment variable; Pydantic AI reads it the same way the underlying SDK would. The model string format is 'provider:model-id', so Claude Sonnet is 'anthropic:claude-sonnet-5-5'.
1pip install pydantic-ai2export ANTHROPIC_API_KEY="sk-ant-..."Expected result: python -c "import pydantic_ai" runs with no import errors.
Define the structured output you want back
Before writing any tool, define what a correct answer looks like as Pydantic models: DayPlan (a day number, a list of activities, and an estimated cost) and Itinerary (a destination, a list of DayPlan, and a total cost). This is the contract the agent's final answer must satisfy, decided before you write a single line of agent logic.
1from pydantic import BaseModel23class DayPlan(BaseModel):4 day: int5 activities: list[str]6 estimated_cost_usd: float78class Itinerary(BaseModel):9 destination: str10 days: list[DayPlan]11 total_cost_usd: floatExpected result: Two plain Pydantic models you could validate against sample data by hand, with no agent involved yet.
Define a Deps dataclass for dependency injection
Deps holds whatever a tool needs to do real work, here a placeholder for an API client, in production a real flights/hotels API wrapper. You'll pass an instance of it into run_sync() later, and every tool's RunContext carries it through ctx.deps. This is the idiomatic alternative to a tool reaching for a module-level client or an API key baked into a closure.
1from dataclasses import dataclass23@dataclass4class Deps:5 travel_api_client: object # swap for a real flights/hotels API clientExpected result: A dataclass with no logic of its own, just a typed container for what tools will need.
Define tools with @agent.tool and RunContext
Each tool takes ctx: RunContext[Deps] as its first parameter, which doesn't appear in the schema sent to the model, then the parameters the model actually fills in. search_flights and search_hotels both read ctx.deps.travel_api_client instead of calling a global. The docstring is still the only thing the model reads to decide when to call each one.
1from pydantic_ai import RunContext23@agent.tool4def search_flights(ctx: RunContext[Deps], origin: str, destination: str, date: str) -> str:5 """Search for flights between two cities on a given date. Returns price and duration."""6 return f"{origin} -> {destination} on {date}: $420, 6h10m nonstop"78@agent.tool9def search_hotels(ctx: RunContext[Deps], destination: str, nights: int) -> str:10 """Search for hotels in a destination for a number of nights. Returns name and total price."""11 return f"Riverside Inn, {destination}: ${120 * nights} for {nights} nights"Expected result: Two tool functions whose first parameter is never part of what the model sees or fills in.
Create the Agent with deps_type, output_type, and a system prompt
Agent() takes the model string, deps_type (the Deps class from step 3), output_type (the Itinerary model from step 2), and a system_prompt. deps_type and output_type are what make this agent type-safe end to end: your IDE and your test suite both know exactly what a call returns.
1from pydantic_ai import Agent23agent = Agent(4 "anthropic:claude-sonnet-5-5",5 deps_type=Deps,6 output_type=Itinerary,7 system_prompt=(8 "You are a travel planner. Always search flights and hotels before "9 "building a day-by-day plan with realistic costs."10 ),11)Expected result: An agent object whose .run_sync() calls will only ever return an Itinerary, or raise a validation error you can catch.
Run it, read the typed result, and harden it
Call run_sync() with the user's request and a Deps instance. result.output is an Itinerary object, not a string, access result.output.total_cost_usd directly. For production, wrap the call in a try/except for pydantic_ai's result-validation errors (the model gave an answer that doesn't fit your schema after retries) and never call run_sync() from code that's already inside an async event loop, use the async agent.run() there instead.
1deps = Deps(travel_api_client=None) # swap for a real client2result = agent.run_sync(3 "Plan a 3-day trip to Lisbon leaving from Boston on 2026-12-10.",4 deps=deps,5)6print(result.output.destination, result.output.total_cost_usd)7for day in result.output.days:8 print(day.day, day.activities, day.estimated_cost_usd)Expected result: A printed, fully-typed Itinerary with per-day activities and costs, validated before your code ever touches it.
Complete working example
1"""Travel-itinerary agent built with Pydantic AI: typed tools, typed output.2Run: python travel_agent.py (pip install pydantic-ai; set ANTHROPIC_API_KEY)3"""4from dataclasses import dataclass56from pydantic import BaseModel7from pydantic_ai import Agent, RunContext8910class DayPlan(BaseModel):11 day: int12 activities: list[str]13 estimated_cost_usd: float141516class Itinerary(BaseModel):17 destination: str18 days: list[DayPlan]19 total_cost_usd: float202122@dataclass23class Deps:24 travel_api_client: object # swap for a real flights/hotels API client252627agent = Agent(28 "anthropic:claude-sonnet-5-5",29 deps_type=Deps,30 output_type=Itinerary,31 system_prompt=(32 "You are a travel planner. Always search flights and hotels before "33 "building a day-by-day plan with realistic costs."34 ),35)363738@agent.tool39def search_flights(ctx: RunContext[Deps], origin: str, destination: str, date: str) -> str:40 """Search for flights between two cities on a given date. Returns price and duration."""41 # ctx.deps.travel_api_client would make the real call in production42 return f"{origin} -> {destination} on {date}: $420, 6h10m nonstop"434445@agent.tool46def search_hotels(ctx: RunContext[Deps], destination: str, nights: int) -> str:47 """Search for hotels in a destination for a number of nights. Returns name and total price."""48 return f"Riverside Inn, {destination}: ${120 * nights} for {nights} nights"495051def main():52 deps = Deps(travel_api_client=None)53 result = agent.run_sync(54 "Plan a 3-day trip to Lisbon leaving from Boston on 2026-12-10.",55 deps=deps,56 )57 itinerary = result.output58 print(f"{itinerary.destination}: ${itinerary.total_cost_usd:.2f} total")59 for day in itinerary.days:60 print(f" Day {day.day}: {', '.join(day.activities)} (${day.estimated_cost_usd:.2f})")616263if __name__ == "__main__":64 main()65Common mistakes
Why it's a problem: Setting output_type=str and regex-parsing a structured answer out of free text
How to avoid: Define a Pydantic model for the shape you actually want and pass it as output_type. Pydantic AI retries internally until the model's answer validates against it.
Why it's a problem: Reading an API client or key from a global variable inside a tool function
How to avoid: Inject it through deps_type and read it from ctx.deps inside the tool. It's testable (swap in a fake client) and makes every dependency explicit.
Why it's a problem: Forgetting ctx: RunContext[Deps] as a tool's first parameter
How to avoid: Without it, the tool has no typed way to reach deps, and Pydantic AI won't know to exclude that parameter from the schema it sends to the model.
Why it's a problem: Calling run_sync() from code that's already inside an async event loop (a web server handler, for example)
How to avoid: run_sync() can't run inside a running loop. Use the async agent.run() there instead and await it.
Why it's a problem: Reading result.data instead of result.output
How to avoid: Pydantic AI 2.x renamed the result attribute to .output. Code copied from an older tutorial that uses .data will raise an AttributeError.
Best practices
- Model every agent's final answer as a Pydantic class and pass it as output_type, rather than parsing text after the fact.
- Inject dependencies through deps_type and RunContext instead of module-level clients or closures.
- Keep tool docstrings as the single source of truth the model reads; don't duplicate that explanation in a separate prompt.
- Use the async agent.run() inside any code that's already async; reserve run_sync() for scripts and notebooks.
- Pin pydantic-ai to a known 2.x version; the result attribute and a few APIs changed between 1.x and 2.x.
- Write a quick test that calls a tool function directly with a fake Deps, no model involved, before testing the full agent.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a Pydantic AI travel agent with search_flights and search_hotels tools, typed Deps injection, and an Itinerary output_type (code below). Add a third tool, check_visa_requirements, and make the system prompt call it before building the itinerary for international trips. [paste complete_code]
In Claude Code, open travel_agent.py and ask: 'Add a retry policy for ModelRetry inside search_flights when the mock API returns no results, and a unit test that calls search_flights directly with a fake Deps.'
Frequently asked questions
Why use Python specifically to build an AI agent?
Python has the deepest ecosystem of agent frameworks (LangChain, Pydantic AI, Google ADK, CrewAI) and the most examples to learn from. Pydantic AI in particular leans into Python's type system, so IDEs and test suites can check an agent's inputs and outputs the same way they check any other function.
What does Pydantic AI add over just calling the Claude or OpenAI API directly?
Automatic tool-schema generation from type hints, a typed dependency-injection system (RunContext and deps_type), and an output_type that the agent's final answer is validated and retried against, instead of you parsing free text yourself.
What's the difference between output_type and just asking the model to return JSON?
Asking for JSON in the prompt is a suggestion the model can ignore or get slightly wrong. output_type is enforced: Pydantic AI validates the response against your model and retries internally if it doesn't fit, so your code only ever sees a valid object.
Can I use Pydantic AI with models other than Claude?
Yes. The model string takes a provider prefix (anthropic:, openai:, and others), so swapping providers is a one-line change as long as the tool-calling behavior you depend on is supported by that model.
How do I give a Pydantic AI agent memory across multiple calls?
Pydantic AI itself handles a single run; for multi-turn memory, pass the previous run's messages (result.all_messages()) into the next run_sync() or agent.run() call, or pair it with a durable-execution integration like Temporal for longer-running conversations.
Can someone build this for me instead of doing it myself?
Yes. RapidDev builds production Python agents on Pydantic AI, LangChain, and the Claude Agent SDK, including the dependency injection and validation patterns this guide introduces. A free 30-minute consultation is the fastest way to scope your version.
Talk to an Expert
Our team has built 1,000+ apps. Get personalized help with your project.
Book a free consultation