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.
Written and reviewed by Shreyash Prashu Last reviewed
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 (Messages API) — schema goes in output_config.format.
client.messages.create(
model="claude-opus-5-5",
max_tokens=2048,
output_config={"format": {
"type": "json_schema",
"schema": ExtractedInvoice.model_json_schema(),
}},
messages=[{"role": "user", "content": prompt}],
)
Both paths end the same way — the reply is JSON text that matches the schema, and ExtractedInvoice.model_validate_json(...) turns it into a typed object. (Each SDK also has a parse helper that takes the Pydantic class directly and does that last step for you.) 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
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
Model.model_json_schema() return?json.loads on raw LLM text?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.
I evaluate LLM outputs with deterministic checks, task-specific metrics, human review, and calibrated model-based judgments rather than relying on one score. LLM-as-a-judge uses a capable model to score or compare answers against an explicit rubric, which scales evaluation but must be checked for bias, weak grounding, and disagreement with human reviewers.
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.