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 →

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:

python
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

python
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 ticket

Running a valid and an invalid order id

Example
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)

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 ModelRetry sent your message back to the model, and on attempt 1 it returned order_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 ruleOutput validator
ChecksThe value itself: range, patternFacts needing data, an API or the run
Shows in the schemaYesNo
Can be asyncNoYes

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.
Try it yourself
  • Raise ModelRetry for tickets with category="other" and see how many attempts run.
  • Make the validator async and await asyncio.sleep(0) inside it.
  • Remove the second sentence from the ModelRetry message. What would a real model do differently?

This is what real progress feels like.