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 →
Output validators: rules Pydantic cannot check
An output validator is a function that runs after Pydantic on the validated output and can send the model back with ModelRetry for rules Pydantic cannot check.
Last updated: 28 Sep, 2026 · Pydantic AI 2.51
The retries in Validation retries: when the model gets it wrong fixed shape errors. Some rules need your data, such as whether an order exists. Pydantic can check that order_id is a string, not that it is in your database.
Defining the model and the reader
ORDERS is your data. The model function copies whatever looks like an id, and drops it after a retry:
import re
from typing import Literal
from pydantic import BaseModel
from pydantic_ai import Agent, ModelResponse, ModelRetry, RunContext, ToolCallPart
from pydantic_ai.models.function import FunctionModel
ORDERS = {"A-1001": "shipped", "A-1002": "waiting for stock"}
class Ticket(BaseModel):
category: Literal["billing", "shipping", "other"]
order_id: str | None
def reader(messages, info):
last = messages[-1].parts[-1]
ticket = messages[0].parts[-1].content
found = re.search(r"A-\d+", ticket)
order_id = None if last.part_kind == "retry-prompt" or not found else found.group()
return ModelResponse(parts=[ToolCallPart("final_result", {"category": "billing", "order_id": order_id})])Registering an output validator
agent = Agent(FunctionModel(reader), output_type=Ticket)
@agent.output_validator
def order_exists(ctx: RunContext, ticket: Ticket) -> Ticket:
print("attempt", ctx.retry)
if ticket.order_id is not None and ticket.order_id not in ORDERS:
raise ModelRetry(f"There is no order {ticket.order_id}. Use null if the ticket has no valid order id.")
return ticketRunning a valid and an invalid order id
print(agent.run_sync("I was charged twice for order A-1001").output)
print("---")
print(agent.run_sync("I was charged twice for order A-10001").output)Output
attempt 0 category='billing' order_id='A-1001' --- attempt 0 attempt 1 category='billing' order_id=None
What the two runs did
- A-1001 passed on attempt 0, because it is in
ORDERS. - The typo A-10001 is not an order, so raising
ModelRetrysent your message back to the model, and on attempt 1 it returnedorder_id=None. - ctx.retry counts the retries so far; the validator's first argument, a
RunContext, describes the run. - ModelRetry uses the same retry allowance as a validation error, so it can run out and raise.
A field rule vs an output validator
| Field rule | Output validator | |
|---|---|---|
| Checks | The value itself: range, pattern | Facts needing data, an API or the run |
| Shows in the schema | Yes | No |
| Can be async | No | Yes |
When to reach for a validator
- Confirming an id, a code or a name exists in your own records.
- A rule that needs the run's context or a network call.
- Anything you want retried with a message written for the model.
Watch out. The
ModelRetry message is the only thing the model learns about the failure. Say what was wrong and what to do instead, or the next attempt repeats the mistake.Related
- Previous: Validation retries: when the model gets it wrong
- Next: Output functions and several output types
- Reference: Output validators
Try it yourself
- Raise
ModelRetryfor tickets withcategory="other"and see how many attempts run. - Make the validator
asyncandawait asyncio.sleep(0)inside it. - Remove the second sentence from the
ModelRetrymessage. What would a real model do differently?
This is what real progress feels like.