Get structured JSON from an LLM¶
Beginner → Intermediate · 12 min · tested with openai 3.x and pydantic 2
Chatbots return prose. Real applications need data: a ticket category to route on, fields to save in a database, a list of extracted entities. The goal is to go from a messy customer email to a typed Python object you can trust, every time.
1. Why "reply in JSON" isn't enough¶
Asking nicely mostly works, until it doesn't:
The model added a friendly sentence and a code fence, so json.loads() crashes. Other days it forgets a field,
invents a category you don't handle, or writes "urgency": "high" instead of a number. You can patch around it:
import json
import re
def extract_json(text: str) -> dict:
match = re.search(r"\{.*\}", text, re.S) # grab from the first { to the last }
return json.loads(match.group(0))
print(extract_json('Sure! Here is the JSON:\n```json\n{"intent": "refund", "urgency": 4}\n```'))
# → {'intent': 'refund', 'urgency': 4}
But that only fixes the wrapping, not wrong fields or values. The real fix is to give the model a schema and let the API enforce it.
2. Describe the output as a Pydantic model¶
from typing import Literal
from pydantic import BaseModel, Field
class SupportTicket(BaseModel):
name: str
email: str
intent: Literal["refund", "delivery", "complaint", "other"]
order_id: str | None = Field(description="Order ID like A100, or null if not mentioned")
urgency: int = Field(ge=1, le=5, description="1 = can wait, 5 = urgent")
Each piece helps the model:
Literalgives a closed list of choices, so you never get a category your code doesn't handle.str | Nonewith a description tells it what to do when the information is missing (instead of inventing an order ID).Field(description=...)is read by the model. Write it like an instruction.
3. Structured Outputs with the OpenAI SDK¶
Pass the model class as response_format to .parse(). The API constrains generation to your schema, and the SDK
hands back an instance of your class:
from openai import OpenAI
client = OpenAI()
email = """Hi, this is Asha Rao (asha@example.com). My order A100 arrived broken
and I need my money back before the weekend, please!"""
completion = client.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Extract a support ticket from the customer's email."},
{"role": "user", "content": email},
],
response_format=SupportTicket,
)
message = completion.choices[0].message
if message.refusal: # the model can decline (e.g. harmful content)
print("Refused:", message.refusal)
else:
ticket = message.parsed # a SupportTicket, already validated
print(ticket)
print(ticket.intent, ticket.urgency)
# → name='Asha Rao' email='asha@example.com' intent='refund' order_id='A100' urgency=4
# → refund 4
ticket is a real object: ticket.urgency is an int, your editor autocompletes the fields, and
ticket.model_dump() gives a dict for your database. Behind the scenes the SDK converted the class to a JSON schema
and sent it as response_format={"type": "json_schema", ...}.
import anthropic
claude = anthropic.Anthropic()
response = claude.messages.parse(
model="claude-opus-5-5",
max_tokens=1024,
system="Extract a support ticket from the customer's email.",
messages=[{"role": "user", "content": email}],
output_format=SupportTicket,
)
ticket = response.parsed_output # a SupportTicket
print(ticket.intent, ticket.urgency)
If a provider or model doesn't support schemas, JSON mode at least guarantees valid JSON (not the right fields). Mention JSON and the fields in the prompt, then validate yourself:
reply = client.chat.completions.create(
model="gpt-4o-mini",
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": "Return JSON with keys: name, email, intent, order_id, urgency (1-5)."},
{"role": "user", "content": email},
],
)
ticket = SupportTicket.model_validate_json(reply.choices[0].message.content)
4. Validate and retry¶
Schemas guarantee the shape. They can't guarantee the values make sense, and JSON mode guarantees even less. Pydantic catches what's wrong, with messages precise enough to send back to the model:
from pydantic import ValidationError
bad = '{"name": "Ravi", "email": "r@x.com", "intent": "cancel", "order_id": null, "urgency": 9}'
try:
SupportTicket.model_validate_json(bad)
except ValidationError as err:
for e in err.errors():
print(e["loc"], e["msg"])
# → ('intent',) Input should be 'refund', 'delivery', 'complaint' or 'other'
# → ('urgency',) Input should be less than or equal to 5
A retry loop that feeds the errors back:
def extract_ticket(email: str, max_attempts: int = 3) -> SupportTicket:
messages = [
{"role": "system", "content": "Return JSON with keys: name, email, intent, order_id, urgency (1-5)."},
{"role": "user", "content": email},
]
for attempt in range(max_attempts):
reply = client.chat.completions.create(
model="gpt-4o-mini", response_format={"type": "json_object"}, messages=messages,
)
text = reply.choices[0].message.content
try:
return SupportTicket.model_validate_json(text)
except ValidationError as err:
messages += [
{"role": "assistant", "content": text},
{"role": "user", "content": f"That JSON is invalid:\n{err}\nReturn corrected JSON only."},
]
raise ValueError(f"No valid ticket after {max_attempts} attempts")
Add your own business rules as Pydantic validators (for example, order_id must match ^[A-Z]\d{3}$), and they
get the same retry treatment.
5. Extract many items at once¶
Wrap a list in a model to pull every item out of a document in one call:
class Product(BaseModel):
name: str
price_inr: int | None
in_stock: bool
class ProductList(BaseModel):
products: list[Product]
completion = client.chat.completions.parse(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Extract products: Blue kurta Rs 899 (in stock), "
"steel bottle Rs 349 (sold out), gift card (price on request)"}],
response_format=ProductList,
)
for p in completion.choices[0].message.parsed.products:
print(p)
Most APIs need the top level to be an object, which is why the list is wrapped in ProductList.
Tips for schemas the model fills well¶
| Do | Why |
|---|---|
Use Literal / enums for categories |
No surprise values |
Make "might be missing" fields X | None and say when to use null |
Stops invented data |
Put a reasoning: str field first when the task needs judgment |
The model "thinks" before answering the later fields |
| Keep nesting shallow and field names obvious | Fewer mistakes, cheaper tokens |
Use temperature=0 for extraction |
Consistent results |
| Don't stream when you must validate before showing | You can't validate half a JSON object |
Checklist¶
- Output shape is a Pydantic model, passed as
response_format(oroutput_formatfor Claude). - Refusals are handled.
- Categories are
Literals; optional fields allowNonewith a description. - Business rules are validators; failures are retried with the error message, a limited number of times.
- Extraction runs at temperature 0, and a few real examples are covered by tests.
See Structured output & tool calling and Pydantic for GenAI for the deeper version.