Skip to content

PydanticAI: Output Types & Validators

Verified against pydantic-ai==1.102.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:76):

  • type_ — the Pydantic model, dataclass, or callable.
  • name — tool name sent to the model. Default is final_result for single outputs; for multi-type outputs, the type’s name is appended.
  • description — overrides the docstring as the tool’s description.
  • max_retries — output-tool-specific retry budget; overrides the agent-level retries / output_retries.
  • strict — forwarded to providers that support strict JSON schema (OpenAI).

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 — pydantic_ai_advanced_classes_part2.md