In this article, you will learn how to build production-ready AI agents in Python using Pydantic AI, with structured outputs, custom tools, and dependency injection.

Topics we will cover include:

  • How to define Pydantic models for type-safe, validated agent outputs.
  • How to register Python functions as tools the agent can invoke during its reasoning cycle.
  • How to inject runtime dependencies such as database connections and API clients using a typed RunContext.

Building AI Agents in Python with Pydantic AI
Image by Author

Introduction

AI agents are becoming a core part of production software. They query databases, call APIs, reason over results, and return structured outputs. But most AI agent orchestration frameworks used to build them still feel like glue code. They are often untyped, hard to test, and easy to break as systems grow.

Pydantic AI takes a different approach, bringing strong typing, validation, and clear structure to agentic development. Instead of stitching together loosely connected components, Pydantic AI lets you work with familiar Python patterns. Pydantic AI also lets you validate inputs and outputs, define tools in a clean way, and make agent behavior easier to understand. This helps you build agents that are more reliable and easier to maintain in real-world systems.

In this article, you will learn how to:

  • Build a structured agent with clear input and output models using Pydantic AI
  • Add tools that the agent can call safely
  • Inject runtime dependencies in a clean and testable way
  • Use built-in capabilities in Pydantic AI like web search and extended reasoning

By the end, you will have a solid foundation for building useful agents with Pydantic AI. You can find the Colab notebook for reference on GitHub.

Why Pydantic AI?

When you call an LLM directly, the response is a string. It might be JSON, markdown, or something entirely unexpected. Parsing that string into a structured object requires custom logic, error handling, and hope that the model keeps its formatting consistent. In agents that call tools across multiple steps, this fragility compounds fast. Pydantic AI solves this with the following ideas:

Type-safe structured outputs. Define a BaseModel for your agent’s response. The framework instructs the LLM to conform to that schema, validates the response, and retries automatically on failure. You receive a validated Python object, not a string.

Structured output with Pydantic AI

Structured output with Pydantic AI

Function tools with docstring-driven dispatch. Register plain Python functions as tools. The LLM reads their type hints and docstrings to understand what each tool does and when to use it. Your tool definitions are also documentation.

Dependency injection without global state. Agents in production need database connections, API clients, and session data. Pydantic AI provides a type-safe pattern for injecting these at runtime via a RunContext, keeping agent definitions clean and tests easy.

Prerequisites

  • Python 3.9+ installed in your working environment
  • Familiarity with Pydantic BaseModel and type hints
  • Basic understanding of how LLM prompts work
  • An API key from a supported provider; this tutorial uses openai:gpt-4o-mini throughout; the same patterns work identically with Anthropic’s Claude, Google Gemini, and others by only changing the model string

Installing Pydantic AI

First, you need the package installed and your API key available in the environment. Create a virtual environment and install Pydantic AI:

Then set your API key:

Follow a similar procedure for other model providers as well.

Building Your First Agent with Pydantic AI

With the package installed, you can create and run an agent in just a few lines. This confirms your setup works and introduces the two core arguments every agent needs.

The model string follows the "provider:model-name" format. Swapping the prefix — to anthropic: or google-gla: for example — switches providers without changing anything else. The instructions argument sets the system-level persona and behavior for all runs.

Now run the agent and print its output:

Here’s a sample output:

agent.run_sync(...) sends the prompt and blocks until the response arrives. The .output attribute holds the result, a plain string for now. In async applications, use await agent.run(...) instead; the API surface is identical.

Getting Structured, Validated Outputs

A string response is fine for simple Q&A, but most production applications need the LLM to return data in a shape your code can immediately consume — a typed object, not a blob of text to parse. Pydantic AI handles this via the output_type argument. See the structured output docs for a detailed overview.

Start by defining a Pydantic model that describes the data you want back:

Each field maps directly to something you expect the LLM to extract. Field(description=...) annotations give the model extra hints; use them to reduce validation retries.

Next, create the agent with output_type set to your model and run it against some raw text:

You should get a similar output:

You can also check the full validated object like so:

This outputs:

When output_type is set, Pydantic AI converts your model’s fields into a JSON schema and sends it alongside the prompt. The response is validated on arrival; if any field is missing or mistyped, the framework retries automatically before surfacing an error.

Giving Your Agent Tools

Language models have no access to the outside world. Tools bridge this gap: you register Python functions that the LLM can invoke during its reasoning cycle, receive the results, and continue reasoning before producing its final output.

First, define the data source and the output model the agent will return:

Here, NUTRITION_DB is a stand-in for any external data source: a real database, an API, and more. MealSummary is what we want the agent to return after reasoning over the tool results.

Now create the agent and register a lookup tool with @agent.tool_plain:

@agent.tool_plain is for functions that need no access to the run context — just their own arguments. The docstring is not optional: the LLM reads it to decide when to call the tool and how to interpret the result. A vague or missing docstring leads to the model calling tools at the wrong time.

Giving your agent tools in Pydantic AI

Giving your agent tools in Pydantic AI


Finally, run the agent:

Here’s a sample output:

The agent calls get_ingredient_nutrition once per ingredient, accumulates the results, computes totals, and returns a validated MealSummary. Each tool call is a round-trip with the LLM, so keep tool functions lightweight and scope their docstrings tightly.

Dependency Injection in Practice

Hardcoding the nutrition database directly in the module works for demos, but in production your agent needs access to things created at runtime: a live database connection, an authenticated API client, or user session data. Pydantic AI’s dependency injection pattern handles this cleanly via a typed RunContext.

Start by wrapping the data source in a service class:

NutritionService is the contract between the agent and the data layer. In production you would hold a real database connection here; in tests you swap in a mock with no changes to the agent.

Dependency injection in Pydantic AI

Dependency injection in Pydantic AI


Declare the dependency type on the agent, then update the tool to accept a RunContext:

@agent.tool (not tool_plain) is used when the function needs the run context. ctx.deps holds the injected service instance, fully typed.

Inject the service at call time by passing it to deps:

The payoff is clean, isolated testing. Swap in a mock without modifying the agent definition at all:

The agent never knows or cares where the data comes from; the NutritionService interface is the only contract.

Using Built-in Capabilities in Pydantic AI

Pydantic AI ships with composable capabilities that extend your agent without cluttering the constructor. Pass them via the capabilities argument; they work with any supported provider.

Web search gives the agent live internet access. For finer control over domains and usage limits, see the built-in tools docs:

Thinking enables step-by-step reasoning before the final answer, which is useful for complex or ambiguous tasks. Effort levels “low”, “medium”, and “high” map to each provider’s native format. See the thinking docs for provider-specific details:

Capabilities compose cleanly. You can combine them by passing both in the list:

The agent reasons over what to search for, fetches live results, and synthesizes them into a single response.

Summary and Next Steps

Here is what you built:

  • A basic agent with run_sync and a model string, followed by structured output with output_type, turning LLM responses into validated Python objects
  • Custom function tools using @agent.tool_plain and @agent.tool, with docstring-driven dispatch
  • Runtime dependency injection via RunContext and deps_type, keeping agent definitions decoupled from data sources
  • Built-in capabilities for web search and extended thinking, composed via the capabilities parameter

To go deeper, you can explore advanced toolsets and MCP server integration through function tools, along with the full suite of built-in tools. Thinking configurations let you fine-tune provider-specific reasoning behavior, while dependency injection patterns make your system easier to test and maintain. When you pair Pydantic AI with Logfire, you also gain real-time observability across every LLM call, tool invocation, and validation retry, giving you clear insight into how your system behaves in practice.