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.

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 — 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_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?

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.

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

Skip to content