Pydantic AIPydantic AI 2.51 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
29 small wins to finish your pathNext lesson →

Structured output: a typed ticket

Structured output is an output_type you give an agent so the run ends with a validated instance of it instead of text you would parse.

Last updated: 28 Sep, 2026 · Pydantic AI 2.51

Every run so far returned a string. A support queue needs fields: a category and a priority. Give the agent a Pydantic model as its output_type and Pydantic checks the model filled it correctly.

Declaring the output type with a Pydantic model

python
from typing import Literal

from pydantic import BaseModel, Field


class Ticket(BaseModel):
    category: Literal["billing", "shipping", "other"]
    priority: int = Field(ge=1, le=5, description="1 is low, 5 is urgent")
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop_model.py
import re

from pydantic_ai import ModelResponse, TextPart, ToolCallPart
from pydantic_ai.models.function import AgentInfo, FunctionModel


def sort_ticket(text):
    text = text.lower()
    if "charged" in text or "refund" in text:
        return "billing", 4
    if "parcel" in text or "arrived" in text:
        return "shipping", 3
    return "other", 1


def shop_reply(messages, info: AgentInfo) -> ModelResponse:
    prompts = [p.content for m in messages for p in m.parts if p.part_kind == "user-prompt"]
    ticket = prompts[-1]
    last = messages[-1].parts[-1]
    order = re.search(r"A-\d{4}", ticket)

    # 1. The ticket names an order and the agent has a tool: ask for it.
    if order and info.function_tools and last.part_kind == "user-prompt":
        tool = info.function_tools[0].name
        return ModelResponse(parts=[ToolCallPart(tool, {"order_id": order.group()})])

    # 2. A tool answered: write the reply from what it said.
    if last.part_kind == "tool-return" and info.allow_text_output:
        return ModelResponse(parts=[TextPart(f"Order {order.group()}: {last.content}.")])

    # 3. The agent wants a typed answer: fill in its output tool.
    category, priority = sort_ticket(ticket)
    if info.output_tools:
        args = {"category": category, "priority": priority}
        return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args)])

    # 4. Otherwise, plain text.
    return ModelResponse(parts=[TextPart(f"Sorted as {category}.")])


shop_model = FunctionModel(shop_reply, model_name="shop")

Getting a typed Ticket from a run

Example
agent = Agent(shop_model, output_type=Ticket)
result = agent.run_sync("I was charged twice for one order")
print(result.output)
print(type(result.output).__name__)
print(result.output.priority + 1)

result.output is a Ticket, so result.output.priority is an int you can do arithmetic with, and your editor knows its fields.

Seeing the output tool the model is given

A model only produces text and tool calls. Pydantic AI turns Ticket into a tool the model must call to finish, whose arguments are the ticket's fields. A model function can print what it was handed:

Example
def peek(messages, info):
    tool = info.output_tools[0]
    print("output tool:", tool.name)
    print("text allowed:", info.allow_text_output)
    print(json.dumps(tool.parameters_json_schema, indent=2))
    return ModelResponse(parts=[ToolCallPart(tool.name, {"category": "billing", "priority": 4})])


Agent(FunctionModel(peek), output_type=Ticket).run_sync("I was charged twice")

Reading the output tool

  • The output tool is called final_result, and calling it ends the run.
  • text allowed: False: with an output_type that is not str, a plain text answer does not end the run.
  • The schema is Pydantic's JSON Schema for Ticket: the Literal became an enum, and Field's limits and description are there for the model to read.

Tool output vs native and prompted output

ModeHow the data comes backUse when
Tool output (default)An output tool call with the fieldsAlmost any model, tools supported
NativeOutputThe provider's JSON modeThe provider validates JSON server-side
PromptedOutputPlain instructions to return JSONNo tools or JSON mode available

Using types other than models

Any type Pydantic can validate works: int, list[str], a TypedDict. A type that is not an object is wrapped in an object with one field named response. The stand-in knows nothing of that and fills in category and priority, so validation fails:

Example
agent = Agent(shop_model, output_type=list[str])
print(agent.run_sync("hello").output)

The wrapped field response was missing, so the run raised after its retry ran out. What happens between the failure and the raise is Validation retries: when the model gets it wrong.

Watch out. A non-object type such as list[str] is validated under a wrapper field named response, not under the name you might expect. Give the model a named model class when you want the field names to be yours.
Try it yourself
  • Add summary: str to Ticket. What does the stand-in's output fail on?
  • Change category to a plain str and print the schema again.
  • Print result.all_messages()[-1] after a typed run.

You understood something today that you didn't yesterday.