Pydantic Models as Your Output Contract
11 min read · updated August 4, 2026
A Pydantic model is the one place your output shape should be written down. From it you can generate the JSON schema you send to the provider, validate the reply, and get static types in your editor — three artefacts that would otherwise be three copies drifting apart.
One class, three jobs
Everything on this page is Pydantic v2 (pip install "pydantic>=2"). Start with the contract:
# contract.py
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class Ticket(BaseModel):
model_config = ConfigDict(extra="forbid")
category: Literal["billing", "technical", "account", "other"] = Field(
description="The single best category for this support message."
)
urgency: int = Field(
ge=1, le=5,
description="1 = can wait a week, 5 = the customer is blocked right now.",
)
summary: str = Field(
max_length=200,
description="One sentence, in the customer's own terms.",
)
needs_human: bool = Field(
description="True if the message contains a legal threat, a refund "
"demand over 100 EUR, or anything about account deletion.",
)That single class now gives you:
- A JSON schema from
Ticket.model_json_schema(), to send in the request or paste into the prompt. - A validator via
Ticket.model_validate_json(text), which raisesValidationErrorwith a machine-readable list of what was wrong. - A static type.
ticket.urgencyis anintto mypy and to your editor;ticket.urgencyyis an error before you run anything.
The description on each field is not documentation. It is shipped to the model inside the schema, and it is the highest-leverage text in the whole system: needs_human above went from a coin-flip to a rule the moment the description named the three cases instead of saying “whether a human is needed”.
Generating the schema you send
model_json_schema() returns a plain dict following JSON Schema. Where your endpoint supports schema-constrained output, it goes in the request:
payload = {
"model": MODEL,
"messages": messages,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "ticket",
"schema": Ticket.model_json_schema(),
"strict": True,
},
},
}response_format support is uneven, and strict modes impose extra rules of their own — commonly that every property is listed in required and that additionalProperties is false. Pydantic’s output already sets the second when you use extra="forbid"; the first is why optional fields are often expressed as str | None with a default rather than as an omitted key. Check what your endpoint accepts before assuming — a rejected schema comes back as a 400 naming the offending keyword, which is at least a legible failure.Where it is not supported, put the schema in the prompt. It is more tokens and less reliable, but it works everywhere:
import json
SYSTEM = (
"Extract a support ticket from the user's message. "
"Reply with a single JSON object and nothing else. "
"It must validate against this JSON Schema:\n\n"
+ json.dumps(Ticket.model_json_schema(), indent=2)
)Validating what comes back
from pydantic import ValidationError
def to_ticket(raw_text: str) -> Ticket:
return Ticket.model_validate_json(raw_text)
try:
ticket = to_ticket(content)
except ValidationError as exc:
for error in exc.errors():
print(error["loc"], error["msg"], error["type"])
raise# for input like {"category": "Billing", "urgency": 9, "summary": "...",
# "needs_human": "yes", "extra": 1}
('category',) Input should be 'billing', 'technical', 'account' or 'other' literal_error
('urgency',) Input should be less than or equal to 5 less_than_equal
('extra',) Extra inputs are not permitted extra_forbiddenTwo of Pydantic’s behaviours are worth knowing before you rely on them. It coerces in lax mode, which is the default: the string "3" becomes the integer 3, and "yes" does not become True but "true" does. That is usually what you want from a model that puts numbers in quotes. If you would rather know the model got the type wrong, pass strict=True to the validation call or set it in model_config.
And extra="forbid" is a deliberate choice. The default is to ignore unknown keys, which means a model inventing "confidence": 0.4 silently loses it and you never learn that your prompt is producing a field you did not ask for. Forbidding turns that into a validation error you can count.
Repair on failure, in thirty lines
Pydantic’s error list is precise enough to hand straight back to the model, which is what makes the repair loop work as well as it does.
# repair.py
import json
from typing import Callable, TypeVar
from pydantic import BaseModel, ValidationError
T = TypeVar("T", bound=BaseModel)
def extract(
model_cls: type[T],
messages: list[dict],
call: Callable[[list[dict]], str],
*,
max_repairs: int = 1,
) -> T:
"""Call the model, validate against model_cls, repair once on failure."""
attempt_messages = list(messages)
for attempt in range(max_repairs + 1):
text = call(attempt_messages)
try:
return model_cls.model_validate_json(text)
except ValidationError as exc:
if attempt == max_repairs:
raise
problems = "\n".join(
f"- {'.'.join(str(p) for p in err['loc'])}: {err['msg']}"
for err in exc.errors()
)
attempt_messages = messages + [
{"role": "assistant", "content": text},
{"role": "user", "content":
"That did not validate. Fix these problems and reply with "
"the corrected JSON only:\n" + problems},
]
raise AssertionError("unreachable")Two design notes. The repair message is built from messages, not from attempt_messages, so a second repair never accumulates a chain of failed attempts in the context. And max_repairs defaults to one because the second repair almost never succeeds where the first did not — at that point the schema is asking for something the model cannot determine, and the fix is editorial.
If the incoming text may have prose or a fence around it, run it through the parser from parsing model output safely first and then call model_cls.model_validate(value) on the parsed object.
Lists, and why the root is an object
Extracting several things at once — every line item on an invoice, every action from a meeting transcript — is the common case, and it has one constraint worth knowing before you design around it: several strict structured-output modes require the schema’s root to be an object, not an array. A wrapper class satisfies that and turns out to be better design anyway.
from pydantic import BaseModel, ConfigDict, Field
class LineItem(BaseModel):
model_config = ConfigDict(extra="forbid")
description: str
quantity: int = Field(ge=1)
unit_price_cents: int = Field(ge=0, description="Integer cents, never a float.")
class Invoice(BaseModel):
model_config = ConfigDict(extra="forbid")
items: list[LineItem]
currency: str = Field(min_length=3, max_length=3,
description="ISO 4217 code, upper case, e.g. EUR.")
total_cents: int = Field(ge=0)
notes: str | None = Field(default=None,
description="Anything that did not fit the fields.")The wrapper earns its place three times over. It gives strict mode a legal root. It gives you somewhere to put the fields that belong to the document rather than to a row — currency here — which would otherwise be repeated on every item and could disagree between them. And it gives a natural home for a cross-field check that no schema can express:
from pydantic import model_validator
class Invoice(BaseModel):
# ... fields as above ...
@model_validator(mode="after")
def total_matches_items(self) -> "Invoice":
computed = sum(item.quantity * item.unit_price_cents for item in self.items)
if computed != self.total_cents:
raise ValueError(
f"items sum to {computed} but total_cents is {self.total_cents}"
)
return selfThat validator is the most valuable twelve lines on this page. A model transcribing an invoice can misread a digit, and nothing else in the pipeline will notice — the JSON is well formed, every type is right, and the number is simply wrong. An arithmetic check turns a silent data error into a validation failure the repair loop can act on, and it costs nothing to run. Any extraction where the fields constrain each other deserves the equivalent.
float euros. A model that writes 19.99 and a float field that stores it will not sum to the total exactly, and the validator above would then reject correct extractions — money in integers is why the whole codebase should agree on this before the first schema is written.Designing a schema a model can fill in
Validation failures are usually schema failures. These four changes remove most of them:
- Prefer
Literaltostr. A closed set makes the wrong answer impossible to express and turns a fuzzy judgement into a choice. Enums versus free text is the longer argument. - Add an explicit escape hatch. If there is no
"unknown"or"other"member, the model must pick a wrong one — you have removed its ability to abstain. See abstention. - Keep it flat and shallow. Deeply nested objects have markedly worse fill-in rates than a flat object with prefixed keys, and they are harder to validate usefully. Nested schemas covers when the nesting is worth it.
- Do not ask for a
confidencefloat unless you have anchored it. A free-floating 0-to-1 self-assessment clusters at 0.9 and means nothing. Ask for a three-way band with each band described, or use logprobs if the endpoint exposes them.
Custom validators catch the cross-field rules a schema cannot express — for instance, that a high urgency must come with a human flag:
from pydantic import model_validator
class Ticket(BaseModel):
# ... fields as above ...
@model_validator(mode="after")
def urgent_means_human(self) -> "Ticket":
if self.urgency == 5 and not self.needs_human:
raise ValueError("urgency 5 requires needs_human=true")
return selfv1 and v2, and which names moved
Pydantic v2 renamed most of the public API. If you are reading a tutorial written before it, or maintaining a codebase pinned to v1, these are the ones that bite. Check pydantic.VERSION if you are unsure which you have.
| v1 | Description |
|---|---|
| Model.parse_raw(s) | Model.model_validate_json(s) |
| Model.parse_obj(d) | Model.model_validate(d) |
| instance.dict() | instance.model_dump() |
| instance.json() | instance.model_dump_json() |
| Model.schema() | Model.model_json_schema() |
| @validator("f") | @field_validator("f"), and it must be a @classmethod |
| class Config: | model_config = ConfigDict(...) |
Both can be installed at once — v2 ships pydantic.v1 as a compatibility namespace — but running both in one process is a source of confusing errors where a model from one is passed to a validator from the other. Pick one per project.