The gap between "AI returned something useful" and "AI returned something I can actually use in code" is almost always structured output.
Raw text is fine for reading. The moment you need to extract a field, save a record to a database, or feed an LLM response into the next step of a pipeline, you need predictable structure. Freeform prose breaks everything.
This guide covers every reliable method for getting structured output from OpenAI, Anthropic, and Gemini — with real, copy-pasteable code.
Why Plain Prompting Fails
You've probably tried this:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Return the following as JSON: name, email, company extracted from 'Hi I'm Alice at Acme Corp, alice@acme.com'"}]
)
Sometimes you get clean JSON. Sometimes you get:
Here is the extracted information as JSON:
```json
{
"name": "Alice",
...
The markdown fences break json.loads(). Or the model adds a trailing comment. Or it wraps the JSON in an explanation. You end up writing fragile parsing logic to handle all the variants.
The structured output APIs exist specifically to eliminate this.
OpenAI: response_format with JSON Schema
OpenAI's most reliable structured output method. Pass a JSON schema and the model guarantees it returns valid JSON matching that schema — no fences, no deviations.
Basic JSON Mode
from openai import OpenAI
import json
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "You extract structured data from text."},
{"role": "user", "content": "Extract info from: 'Hi, I'm Alice Chen, VP of Engineering at Acme Corp. Email: alice@acme.com'"}
],
response_format={"type": "json_object"} # guarantees valid JSON
)
data = json.loads(response.choices[0].message.content)
print(data) # {"name": "Alice Chen", "title": "VP of Engineering", "company": "Acme Corp", "email": "alice@acme.com"}
Strict JSON Schema (Recommended)
For maximum control — define the exact shape you expect:
from openai import OpenAI
import json
client = OpenAI()
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"company": {"type": "string"},
"seniority": {
"type": "string",
"enum": ["junior", "mid", "senior", "executive"]
},
"topics_of_interest": {
"type": "array",
"items": {"type": "string"}
}
},
"required": ["name", "email", "company", "seniority", "topics_of_interest"],
"additionalProperties": False
}
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "Extract structured contact information from the email signature."},
{"role": "user", "content": """
Alice Chen | VP of Engineering, Acme Corp
alice@acme.com | Building AI infrastructure | Interested in: RAG, agents, evals
"""}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "contact_extraction",
"strict": True,
"schema": schema
}
}
)
data = json.loads(response.choices[0].message.content)
print(data["name"]) # "Alice Chen"
print(data["seniority"]) # "executive"
print(data["topics_of_interest"]) # ["RAG", "agents", "evals"]
With Pydantic (Cleanest Approach)
OpenAI's Python SDK supports passing a Pydantic model directly — it generates the schema and parses the response automatically:
from openai import OpenAI
from pydantic import BaseModel
from typing import List, Literal
client = OpenAI()
class ContactInfo(BaseModel):
name: str
email: str
company: str
seniority: Literal["junior", "mid", "senior", "executive"]
topics_of_interest: List[str]
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[
{"role": "system", "content": "Extract contact information."},
{"role": "user", "content": "Alice Chen | VP of Engineering, Acme Corp | alice@acme.com | Interested in RAG and agents"}
],
response_format=ContactInfo, # pass Pydantic class directly
)
contact = response.choices[0].message.parsed # returns a ContactInfo instance
print(contact.name) # "Alice Chen"
print(contact.email) # "alice@acme.com"
print(type(contact)) # <class 'ContactInfo'>
No manual json.loads(). No dict["key"] access. Just typed Python objects.
Anthropic (Claude): Tool Use for Structured Output
Claude's most reliable structured output method is tool use. You define a "tool" whose input schema matches the structure you want — then tell Claude to use it.
import anthropic
import json
client = anthropic.Anthropic()
tools = [
{
"name": "extract_contact",
"description": "Extract structured contact information from text",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Full name"},
"email": {"type": "string", "description": "Email address"},
"company": {"type": "string", "description": "Company name"},
"job_title": {"type": "string", "description": "Job title"},
"key_interests": {
"type": "array",
"items": {"type": "string"},
"description": "Professional interests or focus areas"
}
},
"required": ["name", "email", "company", "job_title", "key_interests"]
}
}
]
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
tools=tools,
tool_choice={"type": "tool", "name": "extract_contact"}, # force this specific tool
messages=[{
"role": "user",
"content": "Extract contact info from: Alice Chen, VP Engineering at Acme Corp. alice@acme.com. Passionate about RAG pipelines and LLM evaluation."
}]
)
# Extract the tool result
tool_use_block = next(block for block in response.content if block.type == "tool_use")
data = tool_use_block.input # already a Python dict
print(data["name"]) # "Alice Chen"
print(data["job_title"]) # "VP Engineering"
print(data["key_interests"]) # ["RAG pipelines", "LLM evaluation"]
With Pydantic Validation
from pydantic import BaseModel
from typing import List
class ContactInfo(BaseModel):
name: str
email: str
company: str
job_title: str
key_interests: List[str]
# Use the same API call as above, then validate
tool_use_block = next(block for block in response.content if block.type == "tool_use")
contact = ContactInfo(**tool_use_block.input) # validates and type-checks
print(contact.name) # "Alice Chen"
print(type(contact)) # <class 'ContactInfo'>
Google Gemini: response_mime_type + Schema
Gemini supports structured output via generation_config:
import google.generativeai as genai
from google.generativeai.types import content_types
import json
genai.configure(api_key="your-gemini-api-key")
model = genai.GenerativeModel("gemini-1.5-pro")
response = model.generate_content(
"Extract contact info from: Alice Chen, VP Engineering at Acme Corp, alice@acme.com",
generation_config=genai.GenerationConfig(
response_mime_type="application/json",
response_schema={
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"company": {"type": "string"},
"job_title": {"type": "string"},
},
"required": ["name", "email", "company", "job_title"]
}
)
)
data = json.loads(response.text)
print(data)
The Universal Pattern: Instructor Library
If you work across multiple providers, Instructor is worth knowing. It wraps OpenAI, Anthropic, and Gemini with a unified Pydantic interface.
import instructor
from anthropic import Anthropic
from openai import OpenAI
from pydantic import BaseModel
from typing import List
class BlogPostMeta(BaseModel):
title: str
slug: str
tags: List[str]
summary: str
target_audience: str
# Works with OpenAI
openai_client = instructor.from_openai(OpenAI())
# Works with Anthropic — same interface
anthropic_client = instructor.from_anthropic(Anthropic())
# Same call pattern for both
result = anthropic_client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
messages=[{
"role": "user",
"content": "Generate metadata for a blog post about LangGraph agents"
}],
response_model=BlogPostMeta,
)
print(result.title) # "Building Stateful AI Agents with LangGraph"
print(result.tags) # ["LangGraph", "agents", "Python", "LLM"]
print(type(result)) # <class 'BlogPostMeta'>
Install with:
pip install instructor
Extracting Lists of Objects
A common real-world task — extract multiple entities from a single block of text:
from openai import OpenAI
from pydantic import BaseModel
from typing import List
client = OpenAI()
class Product(BaseModel):
name: str
price: float
currency: str
in_stock: bool
class ProductList(BaseModel):
products: List[Product]
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[
{"role": "system", "content": "Extract product information from the text."},
{"role": "user", "content": """
Available items:
- MacBook Pro 14" — ₹1,89,990 — In stock
- iPad Air — ₹59,900 — Out of stock
- AirPods Pro — ₹24,900 — In stock
"""}
],
response_format=ProductList,
)
product_list = response.choices[0].message.parsed
for product in product_list.products:
print(f"{product.name}: {product.currency}{product.price} — {'✓' if product.in_stock else '✗'}")
Output:
MacBook Pro 14": ₹189990.0 — ✓
iPad Air: ₹59900.0 — ✗
AirPods Pro: ₹24900.0 — ✓
Classification with Strict Enums
Force the model to pick exactly one option from a defined set:
from openai import OpenAI
from pydantic import BaseModel
from typing import Literal
client = OpenAI()
class TicketClassification(BaseModel):
category: Literal["billing", "technical", "feature_request", "general"]
priority: Literal["low", "medium", "high", "urgent"]
requires_human: bool
one_line_summary: str
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Classify this customer support ticket."},
{"role": "user", "content": "My payment was charged twice this month and I need a refund ASAP!"}
],
response_format=TicketClassification,
)
ticket = response.choices[0].message.parsed
print(ticket.category) # "billing"
print(ticket.priority) # "urgent"
print(ticket.requires_human) # True
Error Handling and Validation
Even with strict structured output, build in validation:
from pydantic import BaseModel, validator, ValidationError
from typing import List
from openai import OpenAI
client = OpenAI()
class ExtractedData(BaseModel):
email: str
score: int
@validator("email")
def email_must_be_valid(cls, v):
if "@" not in v:
raise ValueError("Invalid email format")
return v.lower()
@validator("score")
def score_must_be_in_range(cls, v):
if not 0 <= v <= 100:
raise ValueError("Score must be between 0 and 100")
return v
try:
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "User email: Alice@Example.COM, engagement score: 87"}],
response_format=ExtractedData,
)
data = response.choices[0].message.parsed
print(data.email) # "alice@example.com" (lowercased by validator)
print(data.score) # 87
except ValidationError as e:
print(f"Validation failed: {e}")
Provider Comparison
| Feature | OpenAI | Anthropic | Gemini |
|---|---|---|---|
| JSON schema enforcement | json_schema mode | Tool use | response_schema |
| Pydantic native support | .parse() beta | Via Instructor | Via Instructor |
| Enum constraint | Yes | Yes (tool schema) | Yes |
| Nested objects | Yes | Yes | Yes |
| Array of objects | Yes | Yes | Yes |
| Strictness guarantee | High (strict mode) | High (tool_choice forced) | Medium |
For production workloads across providers, Instructor + Pydantic gives you the most consistent experience.
The Key Rule
Define your schema before you write your prompt. Start with the Pydantic model that represents the data you need — then build the prompt around extracting that shape. Trying to reverse-engineer a schema from an unreliable LLM output is always harder than designing the schema first and using the API's enforcement mechanisms.
For pipelines that chain multiple structured outputs together, LangChain with Pydantic parsers wraps all of this in a clean composable interface.
