Structured outputs — the bridge between LLMs and code
Pydantic schemas, JSON mode, and Instructor: get typed data out of a model instead of parsing strings.
Why this matters
In production, you do not want the LLM to return prose for your code to parse with regex. You want a typed object. Structured outputs are how you get that, reliably, every time. The phrase "structured outputs" appears in 90%+ of agentic AI job descriptions for a reason — it is the bridge between LLMs and traditional software.
Learning objectives
- Use OpenAI's Structured Outputs (Responses API and Chat Completions) with strict schemas.
- Use Anthropic's tool-use mode for the same outcome.
- Use the
instructorlibrary to do this with any provider in one line. - Add Pydantic validators that auto-retry the LLM on bad output.
- Stream partial structured outputs to a UI.
1. Why naive JSON parsing fails
You have all done this:
text = llm("Return JSON: name and age for John, 30").content
import json
data = json.loads(text) # KABOOMFailures: extra prose around the JSON, single quotes, trailing commas, hallucinated fields, missing required fields. Production systems cannot tolerate this.
The 2026 fix: tell the model and the API to enforce a schema. Three flavors below.
2. OpenAI Structured Outputs — Responses API (recommended in 2026)
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal
client = OpenAI()
class Ticket(BaseModel):
category: Literal["billing", "technical", "account", "other"]
priority: Literal["low", "medium", "high"]
summary: str = Field(..., max_length=120)
resp = client.responses.parse(
model="gpt-4.1-mini",
instructions="Classify this support ticket.",
input="My card was charged twice for invoice #42.",
text_format=Ticket,
)
t: Ticket = resp.output_parsed
print(t.category, t.priority, t.summary)That is it. The SDK converts the Pydantic model to a JSON Schema, the API guarantees the output matches, and output_parsed is a typed Ticket.
Behind the scenes
If you want the raw schema control:
resp = client.responses.create(
model="gpt-4.1-mini",
input="...",
text={
"format": {
"type": "json_schema",
"name": "Ticket",
"strict": True,
"schema": Ticket.model_json_schema(), # Pydantic gives this
}
}
)Critical: for strict=True to work, every object must have:
additionalProperties: false- All properties listed in
required
Pydantic does this automatically when you set model_config = ConfigDict(extra="forbid") on the model:
from pydantic import BaseModel, ConfigDict
class Ticket(BaseModel):
model_config = ConfigDict(extra="forbid")
category: str
priority: str3. OpenAI Structured Outputs — Chat Completions
If you are on the older Chat Completions API, use the parse helper:
completion = client.chat.completions.parse(
model="gpt-4.1-mini",
messages=[
{"role": "system", "content": "Classify the ticket."},
{"role": "user", "content": "My card was charged twice."},
],
response_format=Ticket,
)
ticket = completion.choices[0].message.parsedThere is also a refusal field on the message — the model can refuse the request, in which case parsed is None and refusal has the explanation. Always handle both.
msg = completion.choices[0].message
if msg.refusal:
handle_refusal(msg.refusal)
else:
ticket = msg.parsed4. Anthropic — structured outputs via tool use
Anthropic does not have a dedicated "structured output" param. Instead you define a single tool whose schema is your output, then force the model to call it.
import anthropic
from pydantic import BaseModel
class Invoice(BaseModel):
vendor: str
amount: float
due_date: str
aclient = anthropic.Anthropic()
resp = aclient.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
tools=[{
"name": "extract_invoice",
"description": "Extract structured invoice data.",
"input_schema": Invoice.model_json_schema(),
}],
tool_choice={"type": "tool", "name": "extract_invoice"},
messages=[{"role": "user", "content": "Acme Corp owes us $4,250 by 2026-07-15."}],
)
tool_use = next(b for b in resp.content if b.type == "tool_use")
invoice = Invoice(**tool_use.input)
print(invoice)tool_choice={"type":"tool","name":"..."} forces the call. Without it the model decides.
5. Instructor — provider-agnostic, the easiest path
instructor is a small library that patches OpenAI/Anthropic/Gemini/Ollama clients to take a response_model argument. It also auto-retries when Pydantic validation fails. In 2026 this is the most common pick across teams that want one wrapper.
# uv add instructor
import instructor
from pydantic import BaseModel, field_validator
from typing import Literal
class Order(BaseModel):
item: str
qty: int
payment: Literal["card", "upi", "cod"]
@field_validator("qty")
@classmethod
def positive(cls, v):
if v <= 0:
raise ValueError("qty must be positive")
return v
client = instructor.from_provider("openai/gpt-4.1-mini", max_retries=3)
order = client.create(
response_model=Order,
messages=[{"role": "user", "content": "I want 2 shirts paid by UPI."}],
)
print(order) # Order(item='shirt', qty=2, payment='upi')When validation fails, instructor:
- Catches the
ValidationError. - Sends the error message back to the model.
- Asks it to fix the output.
- Tries again, up to
max_retries.
That validator-driven loop catches things like negative quantities, wrong enums, regex mismatches — as if the LLM had stronger guarantees than it does.
Switch providers in one line
client = instructor.from_provider("anthropic/claude-haiku-4-5")
client = instructor.from_provider("google/gemini-2.5-flash")
client = instructor.from_provider("ollama/llama3.3", mode=instructor.Mode.JSON)Same response_model API everywhere.
6. Streaming partial structured outputs
For UIs that need to show fields as they arrive (e.g. typing fields one by one), instructor and OpenAI both support streaming partials.
from instructor import Partial
stream = client.create(
response_model=Partial[Order],
messages=[{"role":"user","content":"3 books, paid card"}],
stream=True,
)
for partial in stream:
print(partial) # incrementally filled OrderOpenAI Responses API has the same with client.responses.stream(...) plus text_format=Order.
7. When to use which (decision rule)
| Situation | Pick |
|---|---|
| OpenAI only, modern app | Responses API responses.parse(text_format=) |
| OpenAI legacy, modern app | Chat Completions chat.completions.parse(response_format=) |
| Anthropic only | Tool-use trick with forced tool_choice |
| Multi-provider | instructor.from_provider(...) |
| Local/Ollama | instructor with Mode.JSON |
| Need streaming partials | instructor Partial[Model] |
Default in new code: instructor. Reach for native APIs only when you need a feature instructor does not yet expose.
8. Validators are your superpower
Use Pydantic field validators to enforce semantic rules — they trigger instructor's auto-retry.
from pydantic import BaseModel, field_validator
import re
class CompanyEmail(BaseModel):
address: str
@field_validator("address")
@classmethod
def must_be_company(cls, v):
if not re.match(r".*@(acme|globex)\.com$", v):
raise ValueError("must be @acme.com or @globex.com")
return vIf the LLM returns john@gmail.com, instructor catches the error, sends "address must be @acme.com or @globex.com" to the model, and asks again. Two-line guard, big reliability win.
Hands-on lab (3 hours)
Build extractor.py:
- Define a Pydantic model for
BankStatementLine:pythonclass BankStatementLine(BaseModel): date: date amount: float direction: Literal["credit", "debit"] counterparty: Optional[str] category: Literal["food", "transport", "rent", "salary", "other"] - Read 30 lines from
statement.csv(mix clean and messy lines). - For each line, use instructor to extract a
BankStatementLinefrom the raw text. - Add a validator:
amountmust be positive; the LLM should be forced to setdirectioninstead. - On 5 deliberately broken lines (e.g. negative amount), confirm instructor retries and succeeds.
- Save results to
statement_clean.jsonplus a small markdown report on retries used per line.
Acceptance criteria:
- Average retries < 0.5 across the dataset.
- 100% schema-valid output.
- README explains how to swap GPT-4.1-mini for Claude Haiku 4.5 in one line.
Common pitfalls
- Forgetting
extra="forbid"— strict schemas fail silently. - Using
Optional[X]everywhere — model treats them as default-None and lazily skips. - Validators with side effects — keep them pure; they may run many times.
- Schemas with
Union[A, B, C]of >5 types — model gets confused. Use a discriminated union with atypefield. - Asking for very large schemas in one shot — split into stages (basic fields then enrich).
Self-check
- What is the difference between
response_format(Chat Completions) andtext.format(Responses)? - Why does Anthropic structured output use the tool-use trick?
- What does
Partial[Order]give you? - Why does
additionalProperties: falsematter forstrict: true? - How does instructor's auto-retry work end-to-end?
References
Sign in to save your progress and earn badges.