Skip to content
datarekha

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 Last reviewed

9 min read Intermediate Python Lesson 36 of 41

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:

Pydantic modelone declaration.model_json_schema()LLMgeneration constrainedto the schema.model_validate_json()typed object ✓same model
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_json parses 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’s input_schema — same contract.
  • Schema mode enforces shape, not truth — wrong-but-well-formed values still need evals.

Practice

Quick check

0/3
Q1What does Model.model_json_schema() return?
Q2Why is schema-constrained output safer than json.loads on raw LLM text?
Q3When does schema mode NOT help?

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.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
How do you reliably get structured outputs (JSON, typed objects) from an LLM?

Modern 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.

How do you evaluate LLM outputs, and what is LLM-as-a-judge?

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.

What prompt engineering techniques should every LLM practitioner know?

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.

How does an LLM generate text — what is next-token prediction and autoregression?

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.

Related lessons

Explore further