Skip to content

Engineering & Code

Pydantic AI, and How It Differs from LangChain

13 min read AI · LLM · Pydantic AI

I wrote about LangChain recently. I’ve also been exploring Pydantic AI to see if it does the same thing. It does not, and the difference is worth more than a paragraph.

LangChain grew out of the problem of composing LLM steps into pipelines. Pydantic AI grew out of the problem of getting an LLM to hand your program a value your program can actually trust. Those two starting points produce very different libraries, and choosing between them is easier once you have written the same task twice.

Every example here runs locally with Ollama, so there are no API keys and no per-token billing. Versions used throughout: pydantic-ai 2.33.0, pydantic 2.13.4, langchain 1.3.16, and langchain-ollama 1.1.0, running against llama3.2:3b.

What Pydantic AI Actually Does

Pydantic AI comes from the team behind Pydantic, the validation library that sits underneath FastAPI and most of the modern Python data stack. That heritage explains the whole design.

FastAPI took an ugly problem, turning an untrusted HTTP request into a validated Python object, and solved it with type hints. You declare the shape you want, the framework validates the incoming data against it, and your handler receives a real object. Pydantic AI applies the same idea to a language model. You declare the shape you want back, the framework enforces it, and when the model gets it wrong the framework sends the model the validation error and asks again.

The unit of work is the Agent. An agent bundles a model, a set of instructions, a set of tools, the dependencies those tools need, and the type of the answer. You build one at import time and reuse it, the same way you build a FastAPI router once and reuse it.

What Pydantic AI does not ship is as important as what it does. There are no document loaders, no text splitters, no vector stores, and no retrievers in the package. It gained an embeddings interface in the 2.x line, but retrieval augmented generation is something you assemble yourself from a vector database client and a tool function. That is a deliberate scope decision, and it is the single biggest practical difference from LangChain.

Setting Up

Install Ollama from ollama.com and pull the model:

ollama pull llama3.2:3b

Then create a virtual environment and install the framework:

python3 -m venv venv
source venv/bin/activate
pip install pydantic-ai

The pydantic-ai package pulls in every provider integration. If you only want Ollama and care about install size, pip install "pydantic-ai-slim[openai]" gets you there, because Ollama is reached through its OpenAI-compatible endpoint.

Your First Agent

Three objects get you a working call:

from pydantic_ai import Agent
from pydantic_ai.models.ollama import OllamaModel
from pydantic_ai.providers.ollama import OllamaProvider

model = OllamaModel(
    "llama3.2:3b",
    provider=OllamaProvider(base_url="http://localhost:11434/v1"),
)

agent = Agent(
    model,
    instructions="You are a terse network engineer. Answer in one sentence.",
)

result = agent.run_sync("What is the difference between a VLAN and a subnet?")
print(result.output)
A VLAN (Virtual Local Area Network) is a logical network segment within a single physical network, while a subnet is a physical network segment within a larger network, often with a unique IP address range.

A few details matter here.

The base_url ends in /v1, because Pydantic AI talks to Ollama through the OpenAI-compatible Chat Completions endpoint rather than Ollama’s native API. You can shorten the whole model setup to the string "ollama:llama3.2:3b", but only if OLLAMA_BASE_URL is set in the environment. Skip that and you get a clear error rather than a connection failure:

pydantic_ai.exceptions.UserError: Set the `OLLAMA_BASE_URL` environment variable or
pass it via `OllamaProvider(base_url=...)` to use the Ollama provider.

The system prompt is called instructions, and it belongs to the agent rather than to the call. This is the first hint at the design difference. In LangChain you assemble a message list per call. In Pydantic AI the standing behavior is a property of the agent, and the per-call argument is just the user’s question.

run_sync() returns an AgentRunResult, not a string. The answer is on .output, and the accounting is on .usage:

print(result.usage)
RunUsage(input_tokens=48, output_tokens=36, requests=1)

That requests field counts round trips to the model, which becomes interesting once tools and retries enter the picture. There are three ways to run an agent: run_sync() as above, await agent.run() in async code, and agent.run_stream() as an async context manager.

Structured Output Is the Point

This is the feature the rest of the library is built around. Pass a Pydantic model as output_type and the agent hands you an instance of it:

from pydantic import BaseModel, Field
from pydantic_ai import Agent


class Ticket(BaseModel):
    device: str = Field(description="The hostname of the affected device")
    severity: int = Field(description="Severity from 1 (critical) to 5 (cosmetic)", ge=1, le=5)
    component: str = Field(description="The failing component, such as interface, bgp, power")
    summary: str = Field(description="A one line summary of the problem")


agent = Agent(model, output_type=Ticket)

text = (
    "Overnight the core switch core-sw-01 dropped BGP session to its upstream "
    "peer three times. Each flap lasted about 40 seconds. Customer traffic was "
    "affected. Please look at this today."
)

result = agent.run_sync(text)
print(repr(result.output))
Ticket(device='core-sw-01', severity=3, component='bgp', summary='Customer traffic affected by repeated BGP session flaps to upstream peer')

There is no prompt engineering in that script, and no instruction telling the model to reply in JSON. The Field descriptions become the field descriptions in a JSON Schema, the schema goes to the model as a response format, and Ollama’s grammar-constrained decoder enforces it during generation. The ge=1, le=5 bounds are then checked by Pydantic on the way out.

The result is an ordinary Python object. Attribute access works, arithmetic works, and your editor autocompletes the field names:

print(type(result.output))
print(result.output.severity + 1)
<class '__main__.Ticket'>
4

output_type is not limited to a BaseModel. A plain list[str], a TypedDict, a dataclass, or a union of several models all work, and a union is how you model “the answer is either a Ticket or a Rejection”.

Tools Get Typed Dependencies

A tool is a Python function the model can decide to call. Decorate it, and the schema is derived from the signature and the docstring:

from dataclasses import dataclass
from pydantic_ai import Agent, RunContext

INVENTORY = {
    "core-sw-01": {"site": "dfw", "role": "core", "os": "ios-xe 17.9.4"},
    "edge-rtr-02": {"site": "aus", "role": "edge", "os": "junos 22.4R3"},
}


@dataclass
class Deps:
    inventory: dict


agent = Agent(
    model,
    deps_type=Deps,
    instructions="You answer questions about network devices. Use the tools to look up facts. Never guess.",
)


@agent.tool
def get_device(ctx: RunContext[Deps], hostname: str) -> dict:
    """Look up a device in the inventory by hostname."""
    print(f"  [tool] get_device({hostname!r})")
    return ctx.deps.inventory.get(hostname, {"error": "not found"})


result = agent.run_sync("What OS does edge-rtr-02 run?", deps=Deps(inventory=INVENTORY))
print(result.output)
  [tool] get_device('edge-rtr-02')
The edge-rtr-02 device runs Junos 22.4R3 operating system.

The deps argument is the part worth slowing down on. deps_type=Deps declares what the agent needs to run, you pass an instance at call time, and every tool receives it through ctx.deps. Nothing is a module-level global and nothing is closed over. That means the test suite passes a fake inventory, the production path passes a real database session, and neither one requires touching the agent definition.

The generated tool schema is worth printing once, because it explains why docstrings suddenly matter:

@agent.tool_plain
def find_devices(site: str, role: str = "core", limit: int = 10) -> list[str]:
    """Search the inventory for devices.

    Args:
        site: Three letter site code, such as dfw or aus.
        role: Device role to filter on.
        limit: Maximum number of hostnames to return.
    """
    ...
{
  "additionalProperties": false,
  "properties": {
    "site": {
      "description": "Three letter site code, such as dfw or aus.",
      "type": "string"
    },
    "role": {
      "default": "core",
      "description": "Device role to filter on.",
      "type": "string"
    },
    "limit": {
      "default": 10,
      "description": "Maximum number of hostnames to return.",
      "type": "integer"
    }
  },
  "required": ["site"],
  "type": "object"
}

Pydantic AI parses the Args: block and turns each entry into a parameter description, with no configuration. Types become JSON Schema types, defaults become defaults, and parameters without defaults become required. Use @agent.tool_plain when the function does not need ctx, and @agent.tool when it does.

Failed Validation Becomes a Retry

Getting a well-formed object back is only half the problem. The object also has to be correct, and a schema cannot express “this hostname exists in our inventory”. That is what ModelRetry handles:

from pydantic import BaseModel
from pydantic_ai import Agent, ModelRetry, RunContext

KNOWN = ["core-sw-01", "edge-rtr-02"]


class Change(BaseModel):
    device: str
    action: str


agent = Agent(
    model,
    output_type=Change,
    instructions="Extract the requested change. Use the exact inventory hostname.",
    retries=3,
)


@agent.output_validator
def device_must_exist(ctx: RunContext[None], value: Change) -> Change:
    print(f"  attempt -> device={value.device!r}")
    if value.device not in KNOWN:
        raise ModelRetry(
            f"{value.device!r} is not a valid hostname. "
            f"You must copy one of these exactly: {KNOWN}"
        )
    return value


result = agent.run_sync("Shut the uplink on the Dallas core switch, coresw1.")
print(result.output)
print("model calls:", result.usage.requests)
  attempt -> device='coresw1'
  attempt -> device='core-sw-01'

device='core-sw-01' action='shut uplink'
model calls: 3

The model repeated the hostname from the prompt, the validator rejected it, and the message inside ModelRetry went back to the model as a tool error. On the next attempt the model corrected itself. Your code never saw the bad value.

Write the ModelRetry message as an instruction to the model, not as a log line for a human. “Invalid hostname” gives the model nothing to work with. Listing the valid hostnames and telling it to copy one exactly gives it a path to a correct answer. Note also that retries is a hard ceiling: exhaust it and you get an UnexpectedModelBehavior exception rather than a silently wrong result.

Conversation History

Like every LLM framework, Pydantic AI has no hidden session state. Each run is independent, and continuing a conversation means passing the previous messages forward:

first = agent.run_sync("What is the difference between a VLAN and a subnet?")
print(first.output)

second = agent.run_sync(
    "Which one does a router care about?",
    message_history=first.all_messages(),
)
print(second.output)
print("input tokens:", first.usage.input_tokens, "->", second.usage.input_tokens)
A VLAN is a logical group of devices on a single network, while a subnet is a physical division of an IP network into smaller, non-overlapping ranges.
A router cares about subnets, as it uses subnetting to determine the hardware interface and routing decisions.
input tokens: 48 -> 98

The second call resends everything the first one sent, plus the model’s own answer, plus the new question. That is where the jump from 48 input tokens to 98 comes from, and it is why long conversations get slower and, on a paid API, more expensive.

all_messages() returns a list of typed message objects that serialize cleanly to JSON, so persisting a conversation to a database is straightforward. The message list also shows you exactly what happened during a tool-using run:

ModelRequest  ['UserPromptPart']
ModelResponse ['ToolCallPart']
ModelRequest  ['ToolReturnPart']
ModelResponse ['TextPart']

Four messages, two model calls, one tool execution in the middle.

Where the Two Frameworks Diverge

Both libraries can do the tasks above. Writing them twice is what makes the difference legible.

The same extraction, both ways

LangChain handles structured output through with_structured_output():

from langchain_ollama import ChatOllama
from pydantic import BaseModel, Field


class Ticket(BaseModel):
    device: str = Field(description="The hostname of the affected device")
    severity: int = Field(description="Severity from 1 (critical) to 5 (cosmetic)")
    component: str = Field(description="The failing component")
    summary: str = Field(description="A one line summary")


model = ChatOllama(model="llama3.2:3b", temperature=0.0)
structured = model.with_structured_output(Ticket)
ticket = structured.invoke(TEXT)
print(repr(ticket))
Ticket(device='core-sw-01', severity=3, component='BGP', summary='BGP session flaps with upstream peer')

That works, and it is about the same amount of code. The difference shows up when you ask a type checker what you are holding:

reveal_type(ticket)
lc_typed.py:13: note: Revealed type is "dict[Any, Any] | pydantic.main.BaseModel"

LangChain’s with_structured_output() is declared to return a union of dict and BaseModel, because it supports both. Your editor offers no completion for ticket.device, and mypy rejects every field access until you cast. The Pydantic AI equivalent:

result = agent.run_sync(TEXT)
reveal_type(result.output)
pai_typed.py:12: note: Revealed type is "pai_typed.Ticket"

Agent is generic over both its dependency type and its output type, so the concrete type flows all the way through to .output. Run mypy over a script with two typos in it and both get caught before the model is ever called:

typed.py:21: error: "Deps" has no attribute "inventroy"; maybe "inventory"?  [attr-defined]
typed.py:25: error: "Ticket" has no attribute "severty"; maybe "severity"?  [attr-defined]

That is the whole pitch, and whether it matters to you depends on whether you run a type checker in CI. If you do, this is a real change to the feedback loop, because a class of LLM plumbing bug moves from runtime to edit time. If you do not, it is a smaller deal than the Pydantic AI documentation implies.

The same agent, both ways

LangChain 1.x builds tool-using agents with create_agent():

from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_ollama import ChatOllama


@tool(parse_docstring=True)
def get_device(hostname: str) -> dict:
    """Look up a device in the inventory by hostname.

    Args:
        hostname: The device hostname.
    """
    return INVENTORY.get(hostname, {"error": "not found"})


agent = create_agent(
    model=ChatOllama(model="llama3.2:3b", temperature=0.0),
    tools=[get_device],
    system_prompt="You answer questions about network devices. Use the tools. Never guess.",
)

result = agent.invoke({"messages": [{"role": "user", "content": "What OS does edge-rtr-02 run?"}]})
print(type(result))
print(result["messages"][-1].content)
<class 'dict'>
The edge-rtr-02 device runs Junos 22.4R3 operating system.

Same answer, same tool call. Two things differ.

create_agent() returns a compiled LangGraph state graph, and calling it gives you back a dictionary of graph state. The answer lives at result["messages"][-1].content, which is a string index into an untyped dict. Pydantic AI returns result.output. That is a small ergonomic gap on one line and a larger one across a codebase.

The other difference is parse_docstring=True. Leave it off, which is the default, and LangChain puts the entire raw docstring into the tool description and gives the parameters no descriptions at all:

{
  "description": "Look up a device in the inventory by hostname.\n\nArgs:\n    hostname: The device hostname.",
  "properties": {
    "hostname": {"title": "Hostname", "type": "string"}
  }
}

The model still usually works it out, because the text is right there in the description. It is one more thing to remember, and Pydantic AI does not make you remember it.

Scope

Pydantic AILangChain
Core abstractionAgent, generic over deps and outputRunnable, composed with |
Structured outputCentral, typed end to endwith_structured_output(), returns a union
Validation failureFed back to the model via ModelRetryYour code handles it
Tool dependenciesTyped deps_type, injected as ctx.depsClosures, or context_schema and injected args
Document loadersNoneHundreds
Text splittersNoneYes
Vector stores, retrieversNoneDozens of integrations
EmbeddingsYes, since 2.xYes
Graph and workflow enginepydantic-graph, plus durable execution backendsLangGraph
Evaluationpydantic-evals, in the same installLangSmith, a separate hosted product
ObservabilityOpenTelemetry natively, Logfire optionalLangSmith, or OpenTelemetry through a callback
Static type checkingA design goal, verified in CIPartial
Age and ecosystem sizeYounger, smallerOlder, much larger

The two rows that decide most real projects are the retrieval rows and the ecosystem row. If your application is fundamentally about ingesting a pile of documents and searching them, LangChain has already written the connector you need and Pydantic AI has not. If your application is fundamentally about getting a model to produce a value that the rest of your program consumes, Pydantic AI is built for exactly that shape and LangChain treats it as one feature among hundreds.

Which One to Reach For

Reach for Pydantic AI when the model’s answer feeds code rather than a human. Extraction, classification, routing, and structured summarization all fit. It is also the better choice when the surrounding codebase is typed, when you already run FastAPI and Pydantic and want the LLM layer to look like the rest of the application, and when you need tools that take real dependencies such as a database session or an API client.

Reach for LangChain when you need the ecosystem. Retrieval augmented generation over documents, a long tail of provider and vector store integrations, and multi-step chains where composition is the point are all places where the breadth pays for itself. It is also the safer choice if you need an integration that exists in exactly one place, because that place is usually a langchain-community package.

There is no rule that you pick one. A Pydantic AI tool function is an ordinary Python function, so a tool that runs a LangChain retriever and returns the chunks is about six lines. The framework boundary does not have to be the application boundary.

If you want to see what Pydantic AI feels like before committing to either, take the smallest extraction job in your codebase, the one currently held together by a prompt that begs for JSON and a json.loads() wrapped in a try block, and rewrite it as an agent with an output_type. That one file is a fair test, and it takes about fifteen minutes.