Skip to content

PydanticAI: Output Types & Validators

Verified against pydantic-ai==2.43.0 — source modules: pydantic_ai.output, pydantic_ai.agent.

The output_type argument on Agent (or on a run* call) drives how the model returns structured data. PydanticAI ships five “marker” wrappers — ToolOutput, NativeOutput, PromptedOutput, TextOutput, StructuredDict — plus a plain type / union shortcut. The right one depends on what the model natively supports.

from pydantic import BaseModel
from pydantic_ai import Agent
class Answer(BaseModel):
value: int
reasoning: str
agent = Agent('openai:gpt-5.2', output_type=Answer)
result = agent.run_sync('What is 15 + 27?')
print(result.output)
#> value=42 reasoning='...'
print(type(result.output))
#> <class '__main__.Answer'>

Passing a bare type is a shortcut. PydanticAI will pick the right OutputMode based on the model’s profile (ModelProfile.default_structured_output_mode). To override, wrap the type in one of the marker classes below.

Marker classHow it worksWhen to use
bare typeAuto-picks tool / native / prompted from the model profile.Default — you don’t care which mechanism is used.
ToolOutputModel emits a structured “output tool” call that PydanticAI validates.You want to name the tool, set strict, or use multi-type unions on models that lack native JSON schema.
NativeOutputUses the provider’s native structured-outputs API (e.g. OpenAI response_format=json_schema).Model supports native JSON schema and you want maximum fidelity.
PromptedOutputInjects a JSON schema into the system prompt and parses text.Provider has no native structured outputs (local / older models).
TextOutputPasses the model’s plain text through a function.You want a custom parser (splitter, regex, domain extractor).
StructuredDictReturns dict[str, Any] with a runtime-attached JSON schema.Schema is built at runtime (user-defined form, DB-driven).

OutputMode literals (output.py): 'text' | 'tool' | 'native' | 'prompted' | 'image' | 'auto' plus a deprecated 'tool_or_text'. You rarely set the mode directly; picking a marker class above is the supported path.

from pydantic import BaseModel
from pydantic_ai import Agent, ToolOutput
class Fruit(BaseModel):
name: str
color: str
class Vehicle(BaseModel):
name: str
wheels: int
agent = Agent(
'openai:gpt-5.2',
output_type=[
ToolOutput(Fruit, name='return_fruit'),
ToolOutput(Vehicle, name='return_vehicle'),
],
)
result = agent.run_sync('What is a banana?')
print(repr(result.output))
#> Fruit(name='banana', color='yellow')

Arguments (output.py — verified against pydantic-ai 2.43.0):

ArgTypeDefaultNotes
outputtype | callablerequiredPydantic model, dataclass, or async callable
namestr | NoneNoneTool name sent to the model; auto-derived if unset
descriptionstr | NoneNoneOverrides the type’s docstring as the tool description
max_retriesint | NoneNoneOutput-tool-specific retry budget; overrides the agent-level retries / output_retries
strictbool | NoneNoneForwarded to providers that support strict JSON schema (OpenAI, Anthropic, Google)
sequentialboolFalseAct as a barrier — run alone, not overlapping with other tool calls (only meaningful under end_strategy='exhaustive')

ToolOutput.max_retries — per-output retry budgets

Section titled “ToolOutput.max_retries — per-output retry budgets”

max_retries lets you set a different retry budget for each output branch. Expensive routes get fewer retries; cheap ones can afford more:

from pydantic import BaseModel, field_validator
from pydantic_ai import Agent, ToolOutput
class QuickAnswer(BaseModel):
"""A short factual answer."""
text: str
@field_validator('text')
@classmethod
def non_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError('text must not be empty')
return v
class DetailedReport(BaseModel):
"""A structured report with sections and citations."""
title: str
sections: list[str]
citations: list[str]
@field_validator('citations')
@classmethod
def requires_citations(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError('At least one citation is required')
return v
agent = Agent(
'openai:gpt-4o',
output_type=[
ToolOutput(QuickAnswer, name='quick', max_retries=3), # more forgiving
ToolOutput(DetailedReport, name='report', max_retries=1), # expensive, fail fast
],
)
# The agent chooses which output tool to call based on the question
result = agent.run_sync('What year was Python created?')
print(repr(result.output)) # QuickAnswer(text='1991')
result2 = agent.run_sync('Give me a detailed report on Python history with sources.')
print(repr(result2.output)) # DetailedReport(title=..., sections=[...], citations=[...])

ToolOutput.sequential — barrier output under exhaustive end strategy

Section titled “ToolOutput.sequential — barrier output under exhaustive end strategy”

When end_strategy='exhaustive' the agent runs all tool calls the model emitted in a turn before stopping, including multiple output tools. Setting sequential=True on a ToolOutput makes it act as a barrier: function tools emitted before it finish first, then this output tool runs alone, then tools emitted after it start. Under 'early' (the default) or 'graceful', output tools already run sequentially, so sequential=True has no effect there.

import asyncio
from pydantic import BaseModel
from pydantic_ai import Agent, ToolOutput
class AuditRecord(BaseModel):
"""Audit record — must not overlap with live DB writes."""
action: str
timestamp: str
user_id: int
class AnalysisResult(BaseModel):
"""Analysis result — safe to emit in parallel."""
summary: str
score: float
# AuditRecord must run alone (barrier); AnalysisResult can overlap with others.
agent = Agent(
'openai:gpt-4o',
output_type=[
ToolOutput(AuditRecord, name='audit', sequential=True), # barrier
ToolOutput(AnalysisResult, name='analysis', sequential=False),
],
end_strategy='exhaustive',
)
async def main() -> None:
result = await agent.run('Analyse the last login and log an audit record.')
print(repr(result.output))
asyncio.run(main())

NativeOutput — provider-native JSON schema

Section titled “NativeOutput — provider-native JSON schema”

Uses the provider’s own structured-output mechanism (e.g. OpenAI’s response_format=json_schema). This guarantees the model cannot deviate from the schema and avoids the extra tool-call round-trip of ToolOutput.

from pydantic import BaseModel
from pydantic_ai import Agent, NativeOutput
class Fruit(BaseModel):
name: str
color: str
class Vehicle(BaseModel):
name: str
wheels: int
# Single type — returns Fruit directly
agent_single = Agent('openai:gpt-4o', output_type=NativeOutput(Fruit))
result = agent_single.run_sync('What is a banana?')
print(repr(result.output))
# Fruit(name='banana', color='yellow')
# Multiple types — provider builds a tagged union
agent_union = Agent(
'openai:gpt-4o',
output_type=NativeOutput(
[Fruit, Vehicle],
name='fruit_or_vehicle',
description='Return a fruit or a vehicle.',
),
)
result = agent_union.run_sync('What is a Ford Explorer?')
print(repr(result.output))
# Vehicle(name='Ford Explorer', wheels=4)

OpenAI’s strict mode rejects schemas that include anyOf without a discriminator. Use a Literal discriminator field when strict is required:

from typing import Literal
from pydantic import BaseModel
from pydantic_ai import Agent, NativeOutput
class FruitResult(BaseModel):
kind: Literal['fruit'] = 'fruit'
name: str
color: str
class VehicleResult(BaseModel):
kind: Literal['vehicle'] = 'vehicle'
name: str
wheels: int
agent = Agent(
'openai:gpt-4o',
output_type=NativeOutput([FruitResult, VehicleResult], strict=True),
)

NativeOutput without schema injection (template=False)

Section titled “NativeOutput without schema injection (template=False)”

When your model profile already injects a JSON schema into the system prompt, pass template=False to avoid duplication:

from pydantic_ai import Agent, NativeOutput
agent = Agent(
'ollama:llama3.2',
output_type=NativeOutput(Vehicle, template=False),
)

Arguments (output.py:141):

  • outputs — a type or sequence of types. A list produces a tagged union.
  • name, description — override the schema name and description sent to the model.
  • strict — forwarded to providers supporting strict JSON schema (OpenAI).
  • template — overrides the schema-injection template; pass False to skip.

Availability by provider (verified in models/<provider>.py): OpenAI, Google, Anthropic (via tool adapter), Mistral, Groq. Older/local providers usually fall through to PromptedOutput.

PromptedOutput — schema-in-prompt fallback

Section titled “PromptedOutput — schema-in-prompt fallback”

Works with any model that produces text. The schema is embedded in the prompt and the returned text is parsed.

from pydantic_ai import Agent, PromptedOutput
agent = Agent(
'ollama:llama3.1',
output_type=PromptedOutput(
[Vehicle, Fruit],
template='Respond with JSON matching: {schema}',
),
)

Set template=False if your model profile already injects a schema.

from pydantic_ai import Agent, TextOutput
def split_words(text: str) -> list[str]:
return text.split()
agent = Agent('openai:gpt-5.2', output_type=TextOutput(split_words))
result = agent.run_sync('Who was Albert Einstein?')
print(result.output)
#> ['Albert', 'Einstein', 'was', 'a', 'German-born', 'theoretical', 'physicist.']

The function can optionally take RunContext[Deps] as its first argument.

from pydantic_ai import Agent, StructuredDict
schema = {
'type': 'object',
'properties': {
'title': {'type': 'string'},
'tags': {'type': 'array', 'items': {'type': 'string'}},
},
'required': ['title'],
}
DynamicForm = StructuredDict(schema, name='form_response', description='Fill this form.')
agent = Agent('openai:gpt-5.2', output_type=DynamicForm)
result = agent.run_sync('Make up a blog post.')
print(result.output)
#> {'title': '...', 'tags': [...]}

StructuredDict returns a dict[str, Any] subclass with the schema baked in — use it when the schema is data, not a declared Python type.

Output validators — @agent.output_validator

Section titled “Output validators — @agent.output_validator”

Run arbitrary code after the model produces an output, potentially asking the model to retry.

from pydantic_ai import Agent, ModelRetry, RunContext
agent = Agent('openai:gpt-5.2', output_type=str)
@agent.output_validator
async def no_profanity(ctx: RunContext[None], output: str) -> str:
if 'damn' in output.lower():
raise ModelRetry('Please respond without profanity.')
return output

The validator may:

  • Return the same value (possibly transformed / sanitised).
  • Raise ModelRetry(msg) to feed a retry prompt back to the model.
  • Raise UnexpectedModelBehavior to terminate the run.

Up to output_retries (defaults to the agent retries) validator-triggered retries are allowed before the run fails.

See the streaming guide. StreamedRunResult.get_output() validates the final assembled output using the same output_type pipeline.

  • Union of bare types: output_type=[Fruit, Vehicle] works but you lose per-type tool naming. Use ToolOutput(...) per branch when the model gets confused about which to emit.
  • strict=True (OpenAI): the model rejects any schema with anyOf/oneOf at the root without a discriminator. If you see “strict mode schema rejected” errors, drop strict or use PromptedOutput.
  • NativeOutput on older OpenAI models (pre-gpt-4o-2024-08-06): silently falls back to prompted mode; check result.response.model_name and the raw messages if you need to confirm.
  • TextOutput and tools: when you combine TextOutput with function tools, set end_strategy='graceful' on the agent so tool calls still run before the text is finalised.
  • output_type on run(): the per-run override is only allowed when the agent has no output_validator — the validator’s type wouldn’t match.

1. Discriminated routing between shape types

Section titled “1. Discriminated routing between shape types”
from typing import Literal
from pydantic import BaseModel
class Search(BaseModel):
kind: Literal['search']
query: str
class Action(BaseModel):
kind: Literal['action']
name: str
agent = Agent('openai:gpt-5.2', output_type=Search | Action)

PydanticAI generates a tagged union schema; the kind literal gives the model an unambiguous label to produce.

@agent.output_validator
async def must_contain_sources(ctx: RunContext[None], out: Answer) -> Answer:
if not out.reasoning:
raise ModelRetry('Include a `reasoning` field citing at least one source.')
return out

3. Convert model output into a domain type via TextOutput

Section titled “3. Convert model output into a domain type via TextOutput”
from datetime import date
def parse_iso_date(text: str) -> date:
return date.fromisoformat(text.strip())
agent = Agent('openai:gpt-5.2', output_type=TextOutput(parse_iso_date))

4. Hybrid: structured output plus a free-text summary

Section titled “4. Hybrid: structured output plus a free-text summary”

Use a Pydantic model whose schema contains both the structured and the prose fields; don’t try to mix TextOutput and ToolOutput on the same run.

class Report(BaseModel):
summary: str
findings: list[str]
confidence: float
def build_form_schema(fields: list[dict]) -> type:
schema = {'type': 'object', 'properties': {f['name']: f['schema'] for f in fields}}
return StructuredDict(schema, name='form_response')
agent = Agent('openai:gpt-5.2', output_type=build_form_schema(user_fields))

Override the template to control exactly how the schema is presented to the model. {schema} is the only required placeholder:

from pydantic import BaseModel
from pydantic_ai import Agent, NativeOutput
class Analysis(BaseModel):
topic: str
sentiment: str
key_points: list[str]
TEMPLATE = (
'Analyse the input and return a JSON object that EXACTLY matches:\n'
'```json\n{schema}\n```\n'
'Nothing outside the JSON — no prose, no markdown, just the object.'
)
agent = Agent(
'ollama:llama3.2',
output_type=NativeOutput(Analysis, template=TEMPLATE),
)
result = agent.run_sync('Python 3.13 is fast and developer-friendly.')
print(result.output)

Suppress schema injection (template=False)

Section titled “Suppress schema injection (template=False)”

When the provider sends the schema via API (e.g. OpenAI’s response_format=json_schema), avoid duplicating it in the prompt:

from pydantic import BaseModel
from pydantic_ai import Agent, NativeOutput
class Report(BaseModel):
title: str
summary: str
word_count: int
# OpenAI handles the schema natively — no text injection
agent = Agent('openai:gpt-4o', output_type=NativeOutput(Report, template=False))

Per-call output type override with NativeOutput

Section titled “Per-call output type override with NativeOutput”

Override output_type at run() time for dynamic schemas:

import asyncio
from pydantic import BaseModel
from pydantic_ai import Agent, NativeOutput
class ShortAnswer(BaseModel):
answer: str
class DetailedAnswer(BaseModel):
answer: str
reasoning: str
confidence: float
base_agent = Agent('openai:gpt-4o')
async def main():
# Simple question
r1 = await base_agent.run(
'What is 2+2?',
output_type=NativeOutput(ShortAnswer),
)
print(r1.output.answer)
# Complex question
r2 = await base_agent.run(
'Explain quantum entanglement.',
output_type=NativeOutput(DetailedAnswer),
)
print(r2.output.reasoning)
asyncio.run(main())

Use PromptedOutput when you need the same agent to run across providers with different structured-output support:

import os
from pydantic import BaseModel
from pydantic_ai import Agent, PromptedOutput
class SummaryResult(BaseModel):
title: str
bullet_points: list[str]
word_count: int
# Works with ANY text-capable model
model = os.getenv('MODEL', 'openai:gpt-4o')
agent = Agent(model, output_type=PromptedOutput(SummaryResult))
result = agent.run_sync('Summarise: Python is a high-level language loved for its readability.')
print(result.output)

PromptedOutput with a branded system prompt

Section titled “PromptedOutput with a branded system prompt”
from pydantic import BaseModel
from pydantic_ai import Agent, PromptedOutput
class TechReview(BaseModel):
library: str
rating: int # 1-10
pros: list[str]
cons: list[str]
verdict: str
SCHEMA_TEMPLATE = (
'You are a senior software architect. Evaluate libraries objectively.\n\n'
'Respond ONLY with a JSON object matching:\n{schema}'
)
agent = Agent(
'groq:llama-3.3-70b-versatile',
output_type=PromptedOutput(TechReview, template=SCHEMA_TEMPLATE),
)
result = agent.run_sync('Review the FastAPI web framework.')
print(f'{result.output.library}: {result.output.rating}/10')

TextOutput with RunContext — deps-aware parsing

Section titled “TextOutput with RunContext — deps-aware parsing”

The parser function can take RunContext as its first argument to access dependencies, the run ID, or message history:

import asyncio
import re
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext, TextOutput
@dataclass
class ParseDeps:
decimal_separator: str = '.' # '.' for US, ',' for EU
def parse_price(ctx: RunContext[ParseDeps], text: str) -> float:
"""Extract a price from the model output, respecting locale."""
sep = ctx.deps.decimal_separator
pattern = rf'\b\d{{1,3}}(?:[,\s]\d{{3}})*(?:{re.escape(sep)}\d{{1,2}})?\b'
match = re.search(pattern, text)
if match:
raw = match.group(0).replace(' ', '')
# Only strip commas as thousands separators when they are NOT the decimal separator
if sep != ',':
raw = raw.replace(',', '')
raw = raw.replace(sep, '.')
return float(raw)
return 0.0
agent = Agent(
'openai:gpt-4o',
deps_type=ParseDeps,
output_type=TextOutput(parse_price),
instructions='State prices numerically, e.g. "The cost is 1,499.99".',
)
async def main():
result = await agent.run('How much does a MacBook Pro cost?', deps=ParseDeps(decimal_separator='.'))
print(f'Extracted price: {result.output:.2f}')
asyncio.run(main())
import asyncio
import httpx
from pydantic_ai import Agent, TextOutput
async def translate_to_german(text: str) -> str:
"""Translate the model's English output to German via an external API."""
async with httpx.AsyncClient() as client:
resp = await client.post(
'https://translate.example.com/v1/translate',
json={'text': text, 'target': 'de'},
timeout=10,
)
return resp.json().get('translated', text)
agent = Agent(
'openai:gpt-4o',
output_type=TextOutput(translate_to_german),
instructions='Respond in English.',
)
async def main():
result = await agent.run('Describe Python in one sentence.')
print(result.output) # German translation
asyncio.run(main())
  • Agent.__init__(..., output_type=...)agent/__init__.py:220
  • ToolOutput, NativeOutput, PromptedOutput, TextOutput, StructuredDictoutput.py
  • output_validator decorator — agent/__init__.py:1911
  • OutputSpec type alias — output.py
  • TextOutputFuncoutput.pyCallable[[str], T] | Callable[[RunContext, str], T]
  • Advanced patterns with AgentSpec and output — see Class & API Reference in the comprehensive guide