Pydantic for LLM outputs
The killer use case today — define a Pydantic model, hand its JSON schema to an LLM, and get back typed Python objects. No regex, no broken JSON parsers.
What you'll learn
- Why "just ask the LLM for JSON" is not enough
- Generating a JSON schema from a Pydantic model
- Wiring structured outputs into OpenAI and Anthropic calls
- The two layers of defence — schema constraint plus validation
Before you start
For a couple of years, the dominant way to integrate an LLM looked like this:
response = call_llm("Return JSON for: ...")
data = json.loads(response) # fingers crossed
amount = float(data["amount"]) # fingers crossed again
It worked most of the time — and “most of the time” is exactly the problem in a system that runs a million times a day. Pairing Pydantic with the schema-constrained output mode that modern LLM APIs now offer turns that fragile hope into a contract the model is forced to keep, and hands your code typed objects instead of dicts you have to babysit.
The contract, and the round trip
The idea is a loop with a single source of truth. You declare a Pydantic model; its model_json_schema() becomes the schema you hand the API; the provider constrains the model’s generation, token by token, to satisfy that schema; and you parse the reply straight back into the same model. One declaration, used at both ends:
from pydantic import BaseModel, Field
from typing import Literal
from datetime import date
class LineItem(BaseModel):
description: str
quantity: int = Field(ge=1)
unit_price: float = Field(ge=0)
class ExtractedInvoice(BaseModel):
invoice_number: str
vendor: str
issue_date: date
currency: Literal["USD", "EUR", "INR", "GBP"]
line_items: list[LineItem]
total: float
schema = ExtractedInvoice.model_json_schema()
print("fields:", list(schema["properties"].keys()))
print("line_items type:", schema["properties"]["line_items"]["type"])
fields: ['invoice_number', 'vendor', 'issue_date', 'currency', 'line_items', 'total']
line_items type: array
There is the answer: line_items is an array (and each element is a LineItem object defined in the schema’s $defs). That full schema — field names, types, constraints, the enum on currency, the nested objects — is the entire spec, and the provider uses it to constrain generation. The output cannot come back malformed.
Parsing a response — the code you actually ship
The API call differs by provider (we will see both in a moment), so here we mock the JSON it returns and focus on the part that is identical everywhere — the parse:
from pydantic import BaseModel, Field
from typing import Literal
from datetime import date
class LineItem(BaseModel):
description: str
quantity: int = Field(ge=1)
unit_price: float = Field(ge=0)
class ExtractedInvoice(BaseModel):
invoice_number: str
vendor: str
issue_date: date
currency: Literal["USD", "EUR", "INR", "GBP"]
line_items: list[LineItem]
total: float
# In production this string is the model's schema-constrained reply.
mock_response = '''
{
"invoice_number": "INV-2026-0042",
"vendor": "Datarekha Labs",
"issue_date": "2026-05-12",
"currency": "USD",
"line_items": [
{"description": "Consulting hours", "quantity": 10, "unit_price": 200.0},
{"description": "Storage (TB-month)", "quantity": 3, "unit_price": 25.0}
],
"total": 2075.0
}
'''
invoice = ExtractedInvoice.model_validate_json(mock_response)
# From here it is just Python — typed and validated.
print(invoice.vendor, "-", invoice.invoice_number)
print(f"Total: {invoice.currency} {invoice.total:,.2f}")
for li in invoice.line_items:
print(f" {li.quantity} x {li.description} @ {li.unit_price}")
Datarekha Labs - INV-2026-0042
Total: USD 2,075.00
10 x Consulting hours @ 200.0
3 x Storage (TB-month) @ 25.0
Look at the types that came out. invoice.issue_date is a real date, not a string; invoice.total is a float; invoice.line_items is a list of typed LineItem objects you can loop over with full autocomplete. Your editor knows the fields, your linter catches a typo, and your tests stop having to mock fragile JSON strings.
Provider differences in thirty seconds
The Pydantic model is the same; only the API parameter that carries the schema changes:
# OpenAI (Responses API) — schema goes in text.format.
client.responses.create(
model="gpt-5.1",
input=[{"role": "user", "content": prompt}],
text={"format": {
"type": "json_schema",
"name": "ExtractedInvoice",
"schema": ExtractedInvoice.model_json_schema(),
"strict": True,
}},
)
# Anthropic — schema goes in a tool's input_schema, forced via tool_choice.
client.messages.create(
model="claude-opus-4.7",
max_tokens=2048,
tools=[{
"name": "submit_invoice",
"description": "Submit the extracted invoice",
"input_schema": ExtractedInvoice.model_json_schema(),
}],
tool_choice={"type": "tool", "name": "submit_invoice"},
messages=[{"role": "user", "content": prompt}],
)
Both paths end the same way — ExtractedInvoice.model_validate(...) on the result. The plumbing differs; the contract is identical.
Why this beats regex and bare json.loads
The real strength is two layers of defence: the provider constrains the shape during generation, and Pydantic validates after, catching anything that slips through — an unknown label, an out-of-range probability, a missing field:
from pydantic import BaseModel, Field, ValidationError
class Sentiment(BaseModel):
label: str = Field(pattern="^(positive|negative|neutral)$")
confidence: float = Field(ge=0, le=1)
# If the model goes off-script, validation catches it.
bad = '{"label": "kinda happy", "confidence": 1.5}'
try:
Sentiment.model_validate_json(bad)
except ValidationError as e:
print(e.error_count(), "errors:")
for err in e.errors():
print(" ", err["loc"][0], "->", err["type"])
# Good output passes cleanly.
good = '{"label": "positive", "confidence": 0.92}'
print(Sentiment.model_validate_json(good))
2 errors:
label -> string_pattern_mismatch
confidence -> less_than_equal
label='positive' confidence=0.92
The off-script reply failed on both counts — "kinda happy" is not one of the allowed labels, and 1.5 exceeds the le=1 ceiling on a probability. With that net in place, your downstream code can finally assume valid data and stop defensively re-checking everything.
When schema mode is not enough
Be clear about the limit: schema-constrained output is strong on shape and silent on truth. The model will happily return currency: "USD" even when the invoice plainly says EUR — schema mode cannot enforce correctness, only structure. For that you need evals, spot-checks, or a second pass with a critique step. Pydantic guarantees the data is well-formed; it cannot guarantee the model read the document right.
In one breath
Model.model_json_schema()produces the JSON schema you hand the LLM to constrain its output.- The provider constrains generation to that schema;
model_validate_jsonparses the reply into the typed model. - Two layers of defence: API-side schema constraint, then Pydantic validation for anything semantic.
- OpenAI carries the schema in
text.format; Anthropic carries it in a tool’sinput_schema— same contract. - Schema mode enforces shape, not truth — wrong-but-well-formed values still need evals.
Practice
Quick check
What’s next
You now have typed inputs (Pydantic models) and typed outputs (schema-constrained LLM responses). The next step is shipping them: FastAPI turns a Pydantic model into a validated API endpoint with a single decorator.
Practice this in an interview
All questionsModern APIs offer constrained decoding — the model's token sampling is restricted to only produce tokens that are valid continuations of a JSON schema. Combined with Pydantic validation in application code, this eliminates the JSON-parsing errors that plagued earlier prompt-only approaches. When constrained decoding is unavailable, few-shot examples plus output parsing with retry is the fallback.
LLM evaluation combines reference-based metrics like BLEU and ROUGE, task benchmarks like MMLU and HumanEval, and human or model-based judgment of qualities like helpfulness and faithfulness. LLM-as-a-judge uses a strong model to score or compare outputs against a rubric, scaling human-like evaluation cheaply but requiring care because the judge can be unreliable.
The core toolkit is: system prompts (role and constraints), few-shot examples (format and tone anchoring), chain-of-thought (step-by-step reasoning), and output constraints (JSON schema, stop sequences). Combining these predictably closes the gap between a capable base model and a production-ready feature.
An LLM generates text one token at a time by computing a probability distribution over its entire vocabulary for the next token, sampling from that distribution, appending the result, and repeating — a process called autoregression. Each new token is conditioned on all previously generated tokens, so the output at step N is only as good as the choices made at steps 1 through N-1.