# Pydantic AI Tutorial: Type-Safe AI Agents in Python (2026)

> Pydantic AI tutorial: build type-safe AI agents in Python with structured outputs, tools, dependency injection and native MCP, plus runnable 2026 code.

*Source: https://www.infowok.com/pydantic-ai-tutorial-type-safe-agents-python/ · Sukhveer Kaur · Published June 15, 2026 · Updated July 6, 2026*

---

Everyone reaches for LangChain when they build their first agent. I did too. Then I spent one too many evenings fighting untyped dictionary soup. The model returns a string when I expected a number, and the pipeline falls over three functions later. So I tried [Pydantic AI](https://ai.pydantic.dev/) instead, and a whole class of "the model returned garbage" bugs simply disappeared.

This **Pydantic AI** tutorial builds a type-safe AI agent in Python from scratch. The agent returns a validated object, calls a Python tool, and talks to a remote MCP server. By the end you'll have runnable code. You'll also know when this framework is the right call, and when it isn't.

Pydantic AI comes from the team behind Pydantic, the validation library that already sits under FastAPI. It crossed 17,000 GitHub stars in 2026. It treats type safety as the whole point, not an afterthought. Let's build something with it.

<Prerequisites>

- You can read a Python class and a type hint — new to hints? The [type hints primer](/python-type-hints-primer/) explains them; the [Part 0 primer](/ai-agents-from-scratch-python-part-0/) covers classes
- You've made at least one LLM call before — if not, start with [calling an LLM in Python](/ai-agents-from-scratch-python-part-1/)
- An API key (Google Gemini's free tier is enough), or Ollama for local models

</Prerequisites>

<KeyTakeaways>

- **Pydantic AI makes agents type-safe:** the agent returns a validated object, so a whole class of "the model returned garbage" bugs disappears.
- **Structured output you can trust is the core win** — define the schema, get back a validated Python object instead of dictionary soup.
- **Tools and remote MCP servers plug in as typed toolsets,** with the connection lifecycle tied to the run via `async with agent:`.
- **Reach for it when you need typed, structured output;** reach for something else when you need heavy graph-based orchestration.

</KeyTakeaways>

## What We're Building With Pydantic AI

We're building a small agent that answers a question and hands back a **typed Python object** instead of a blob of text. Your IDE autocompletes the fields. Your type checker catches mistakes before they ship.

![Pydantic AI agent architecture: a prompt goes to the agent, the LLM calls a typed tool and returns JSON, which Pydantic validates into a typed Python object](./pydantic-ai-tutorial-type-safe-agents-python-architecture.svg)

The diagram shows the core loop. Your prompt goes in. The language model does its work, calling a tool if it needs one. Whatever comes back is validated against a Pydantic model before your code touches it. If the shape is wrong, the agent retries. You never parse a raw string by hand again.

This guide suits an intermediate Python developer. You don't need prior agent experience, but you should be comfortable with classes and type hints.

## Prerequisites and Your First Typed Agent

Before the interesting parts, here's the short list of what you need to follow along.

- **Python 3.9+** and a virtual environment
- A model provider key — I use **Google Gemini** because its free tier is plenty for learning and works fine from India
- Ten minutes and a terminal

Install the library and set your key:

```bash
python -m pip install pydantic-ai
export GOOGLE_API_KEY="your-key-here"
```

Now the smallest agent that shows the point of the whole framework. Define a Pydantic model. Pass it as `output_type`. Then ask a question:

```python
from pydantic import BaseModel
from pydantic_ai import Agent

class CityInfo(BaseModel):
    name: str
    country: str
    population: int

agent = Agent(
    "google-gla:gemini-2.5-flash",
    output_type=CityInfo,
    instructions="Return facts about the city the user names.",
)

result = agent.run_sync("Tell me about Bengaluru")
print(result.output)            # CityInfo(name='Bengaluru', country='India', ...)
print(result.output.population) # an int — not the string "13000000"
```

**That `output_type=CityInfo` line is the entire idea.** Pydantic AI turns your model into a JSON schema. It tells the LLM to match that schema. Then it validates the reply and returns a real `CityInfo` object. So `result.output.population` is an `int` your editor knows about.

![Six-step flow for building a type-safe Pydantic AI agent: install, define output model, create the agent, add a tool, connect an MCP toolset, then run and get a typed result](./pydantic-ai-tutorial-type-safe-agents-python-flow.svg)

One small note on that `instructions` argument. Pydantic AI splits the old "system prompt" into `instructions` and `system_prompt`. Use `instructions` for a single agent, which is what we want here. If you've never built an agent before, read [what AI agents actually are](/what-are-ai-agents-complete-guide-2026/) first, then come back.

## Step 1 — Return Structured Data You Can Trust

The early win is simple: you stop writing parsing code. The first time I returned a `CityInfo` object and read `.population` as a number, I deleted about thirty lines of regex and `try`/`except`. That code used to clean up messy model output. Now I don't need it.

Behind the scenes, Pydantic AI validates every response. Say the model returns a missing field, or a string where an int belongs. Pydantic raises a validation error. The agent then **retries the request automatically**. You can tune how many times with `output_retries`:

```python
agent = Agent(
    "google-gla:gemini-2.5-flash",
    output_type=CityInfo,
    output_retries=2,  # retry validation failures up to twice
)
```

You can push validation further with `Field` constraints. Mark a value as positive, and a bad number from the model gets rejected before it reaches you:

```python
from pydantic import BaseModel, Field

class CityInfo(BaseModel):
    name: str
    country: str
    population: int = Field(gt=0)  # must be greater than zero
```

Now a negative or zero population fails validation, and the agent is told to try again. The rule lives in one place — the model — not scattered through `if` checks across your codebase. It's the same `Field` you already use in Pydantic and FastAPI, so there's nothing new to learn.

That reliability isn't free, and I'll come back to the cost. But for any case where model output feeds other code, a guaranteed shape beats a hopeful string. This is the single reason I keep choosing it.

## Step 2 — Give the Agent a Tool

An agent that only knows what's in the model's head is limited. Tools let it reach out, whether to a database, an API, or the filesystem. In Pydantic AI you register a tool with a decorator. **The function's type hints and docstring become the contract the model reads.**

This is where most people get stuck, so read this twice. If you forget the type hints on your tool's parameters, the model can't build a reliable schema, and tool calls get flaky. Here's a tool that looks a user up over HTTP. It injects a dependency through `RunContext`:

```python
import httpx
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext

class UserSummary(BaseModel):
    name: str
    email: str
    company: str

agent = Agent(
    "google-gla:gemini-2.5-flash",
    deps_type=httpx.Client,
    output_type=UserSummary,
    instructions="Look up the user by ID, then return a short summary.",
)

@agent.tool
def fetch_user(ctx: RunContext[httpx.Client], user_id: int) -> dict:
    """Fetch a user profile by numeric ID."""
    resp = ctx.deps.get(f"https://jsonplaceholder.typicode.com/users/{user_id}")
    resp.raise_for_status()
    return resp.json()

with httpx.Client() as client:
    result = agent.run_sync("Summarise user 7", deps=client)
print(result.output.company)
```

The `deps_type` plus `RunContext[httpx.Client]` pattern is dependency injection. You pass in things like clients or connections at runtime, instead of hardcoding them. It keeps the tool testable. You swap a fake client in tests and a real one in production.

> **Common mistake:** using `@agent.tool` for a function that never touches `ctx`. If your tool doesn't need the run context, use `@agent.tool_plain` instead. It drops the `RunContext` argument and reads cleaner.

## Step 3 — Connect a Pydantic AI Agent to an MCP Server

Here's the part that makes Pydantic AI feel current in 2026. It speaks **MCP (Model Context Protocol — an open standard that lets agents call external tools through one interface)** [natively](https://ai.pydantic.dev/mcp/client/). Instead of writing every tool yourself, you point the agent at an MCP server. Its tools then show up automatically.

Each server is a *toolset* you pass to the agent. Connecting to one over streamable-HTTP takes three lines:

```python
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStreamableHTTP

server = MCPServerStreamableHTTP("http://localhost:8000/mcp")
agent = Agent("google-gla:gemini-2.5-flash", toolsets=[server])

async def main():
    async with agent:  # opens/closes the connection around the run
        result = await agent.run("Add 7 and 5 using your tools")
        print(result.output)
```

The `async with agent` block opens connections to every registered server, runs your agent, then cleans up. Note the transport in the URL. For local experiments you can run a server over stdio, but a deployed agent needs HTTP. Streamable-HTTP is the transport you want here, and it's what most 2026 servers expose.

Say you've already built a server, maybe from my guide on [building a production-ready MCP server in Python](/build-mcp-server-python-production/). This is exactly how an agent consumes it. **One standard, and you can swap Gemini for GPT or Claude underneath without touching the tool layer.** That portability is the real reason MCP took over this year.

<Callout type="tip">

Let the type system do the validation. Define your output as a **Pydantic model** and the agent is forced to return data that fits — you catch a malformed response at the boundary instead of three functions deep.

</Callout>

## Step 4 — Testing It and the Errors I Hit

You don't want a live LLM call in your unit tests. It's slow, it costs money, and it gives different answers each run. Pydantic AI ships a `TestModel` that returns canned, schema-valid data. So you can test your wiring offline:

```python
from pydantic_ai.models.test import TestModel

with agent.override(model=TestModel()):
    result = agent.run_sync("Summarise user 7", deps=client)
# assert against result.output here — no API call made
```

A few errors I actually ran into while writing this:

- **`UserError: no API key` —** I forgot to `export GOOGLE_API_KEY` in the new shell. Keys don't carry across terminal sessions.
- **Flaky tool calls —** caused by a tool parameter with no type hint. Add the hint and the schema firms up.
- **`await` missing —** `agent.run()` is async. In a plain script use `agent.run_sync()`, or wrap the async version in `asyncio.run(main())`.

## When to Reach for Something Else — and What to Build Next

Pydantic AI isn't always the answer, and pretending otherwise would waste your time. Say you need a huge ecosystem of prebuilt integrations: dozens of vector stores, retrievers, document loaders. LangChain or LlamaIndex still ship more off-the-shelf parts. There's also a real cost to the safety. **Every validation retry is another billable API call, and it roughly doubles your latency.** So a model that keeps missing your schema gets expensive fast. For a side-by-side on orchestration frameworks, see my [LangGraph vs CrewAI vs AutoGen comparison](/langgraph-vs-crewai-vs-autogen-2026/).

That said, for most single-agent apps where you care about clean output, I prefer Pydantic AI. The type safety pays for itself the first time a model hands you nonsense and the framework catches it, instead of your users. If you came from Java like I did, the near-compile-time guarantees feel like home.

Next, I'd extend this agent with persistent memory so it remembers past turns. That's a natural follow-on to the [agentic AI app series](/build-agentic-ai-app-python-part-1/).

## Frequently Asked Questions

**What is Pydantic AI?**
It's a Python framework from the Pydantic team for building type-safe LLM agents. You define a Pydantic model, pass it as the agent's `output_type`, and get back a validated object instead of a raw string. It supports OpenAI, Anthropic, Gemini, Ollama, and more.

**Is Pydantic AI better than LangChain?**
It depends on the job. I reach for Pydantic AI when I want clean, type-safe output and little boilerplate in a single-agent app. LangChain and LlamaIndex still win when you need a big ecosystem of prebuilt integrations like vector stores and retrievers.

**Does Pydantic AI support MCP?**
Yes, natively. Each MCP server is a toolset — create a client like `MCPServerStreamableHTTP` and pass it to the agent via `toolsets=[...]`, and its tools show up automatically.

**Can I use Pydantic AI with a free model?**
Yes. Google Gemini's free tier is plenty for learning and works fine from India. You can also run local models through Ollama with no API key.

**How does `output_type` make an agent type-safe?**
Pydantic AI turns your model into a JSON schema, tells the LLM to match it, and validates the reply. If the shape is wrong, the agent retries — so `result.output` is always a valid, typed object.

## Conclusion

You've built a type-safe agent that returns a validated object. It calls a tool with injected dependencies. It talks to an MCP server. And it runs under tests without hitting a real model. That's a production-shaped foundation, not a toy.

The obvious next step is to give this agent real tools to call. **Read next: [Build a Production-Ready MCP Server in Python](/build-mcp-server-python-production/).** Write the server, then point this Pydantic AI agent at it.

What's the first tool you'd hand a type-safe agent — a database lookup, an internal API, or something else? Tell me in the comments.

<NextSteps>

- **New to agents?** Start with [What are AI agents?](/what-are-ai-agents-complete-guide-2026/) and the [from-scratch series](/ai-agents-from-scratch-python-part-0/).
- **Give it real tools:** [build a production MCP server](/build-mcp-server-python-production/), then point this agent at it.
- **Comparing frameworks?** [LangGraph vs CrewAI vs AutoGen](/langgraph-vs-crewai-vs-autogen-2026/) covers the orchestration-heavy options.

</NextSteps>
