Course outline
Structured Generation
11 min read · Lesson 2.4
TL;DR: For reliable structured output, use Anthropic's native tool use (function calling) over "respond in JSON" prompts. The Instructor library wraps any provider with Pydantic validation and retry logic. For local models, Outlines guarantees valid JSON via constrained decoding. The "JSON only" prompt instruction works 95% of the time but fails when you need 99.9% reliability.
Prerequisites: Prompting Patterns Related: Tool Use and Function Calling, Context Engineering
The Problem with "Respond in JSON"
Instruction-based structured output is unreliable in production:
# This works most of the time...
prompt = "Extract the name, email, and company. Return JSON only: {\"name\": ..., \"email\": ..., \"company\": ...}"
# But sometimes you get:
# "Sure! Here's the JSON: {\"name\": ...}" ← extra text breaks json.loads
# {"name": "John", "email": null} ← missing field
# {"Name": "John", "Email": "john@..."} ← wrong key capitalization
# {"name": "John", "email": "john@...", "note": "no company mentioned"} ← extra fields
The failure rate is 1-5% per call. For a pipeline making 100 calls, that's 1-5 failures that crash json.loads. In production at scale, you need a real solution.
The reliability spectrum:
Reliability:
"Return JSON" ████████░░ ~95% — good for prototypes
+ json repair █████████░ ~98% — better, still has logic errors
Function calling ██████████ ~99.9% — structural guarantee
Instructor ██████████ ~99.9% + validation
Outlines (local) ██████████ 100% structural — grammar-constrained
Approach 1: Function Calling (Native)
The most reliable approach with Anthropic: define a tool, force the model to use it. The model's output is guaranteed to match the schema because the API enforces it.
from anthropic import Anthropic
import json
client = Anthropic()
def extract_contact_info(text: str) -> dict:
"""Extract structured contact info using tool use."""
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
tools=[{
"name": "extract_contact",
"description": "Extract 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"},
"phone": {"type": ["string", "null"], "description": "Phone number if present"}
},
"required": ["name", "email", "company"]
}
}],
tool_choice={"type": "tool", "name": "extract_contact"},
messages=[{"role": "user", "content": f"Extract contact info from: {text}"}]
)
# The model was forced to call extract_contact
tool_use = next(b for b in response.content if b.type == "tool_use")
return tool_use.input # Already a dict, guaranteed to match schema
Advantages:
- Structural guarantee from the API
- No JSON parsing needed (returns dict directly)
- Handles optional fields correctly (returns null for missing)
- Works with nested schemas, arrays, enums
Disadvantages:
- Only guaranteed to be structurally valid, not semantically correct
- Adds slight latency (the tool-calling overhead is minimal, <50ms)
- Requires the model to support function calling (all modern models do)
Approach 2: Instructor (Recommended for Production)
Instructor wraps function calling with Pydantic validation and automatic retry. Define your schema as a Pydantic model; Instructor handles the rest.
import instructor
from anthropic import Anthropic
from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional
# Define schema as Pydantic model
class ContactInfo(BaseModel):
name: str
email: str
company: str
phone: Optional[str] = None
@field_validator("email")
@classmethod
def validate_email_format(cls, v: str) -> str:
if "@" not in v:
raise ValueError(f"Invalid email: {v}")
return v.lower()
# Patch the Anthropic client with Instructor
client = instructor.from_anthropic(Anthropic())
def extract_with_instructor(text: str) -> ContactInfo:
return client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
response_model=ContactInfo, # Instructor handles the tool schema
messages=[{"role": "user", "content": f"Extract contact info from: {text}"}]
)
contact = extract_with_instructor("John Smith from Acme Corp, reach him at [email protected]")
print(contact.name) # "John Smith" (typed attribute, not dict key)
print(contact.email) # "[email protected]"
print(contact.company) # "Acme Corp"
Instructor's retry logic:
# Instructor automatically retries when validation fails
from instructor import patch
from tenacity import Retrying, stop_after_attempt
client = instructor.from_anthropic(Anthropic())
def extract_with_retry(text: str, max_retries: int = 3) -> ContactInfo:
return client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
response_model=ContactInfo,
max_retries=max_retries, # Retries with error feedback if validation fails
messages=[{"role": "user", "content": f"Extract: {text}"}]
)
When validation fails (e.g., email is "john-at-company.com"), Instructor sends the Pydantic error back to the model and asks it to fix the output. After 3 retries, it raises an exception.
Complex nested schemas:
from typing import List
from pydantic import BaseModel
class LineItem(BaseModel):
description: str
quantity: int
unit_price: float
total: float
class Invoice(BaseModel):
invoice_number: str
vendor: str
line_items: List[LineItem]
subtotal: float
tax_rate: float
total: float
@field_validator("total")
@classmethod
def validate_total(cls, v: float, values) -> float:
expected = values.data.get("subtotal", 0) * (1 + values.data.get("tax_rate", 0))
if abs(v - expected) > 0.01:
raise ValueError(f"Total {v} doesn't match computed {expected:.2f}")
return v
invoice = client.messages.create(
model="claude-opus-4-6",
max_tokens=2048,
response_model=Invoice,
messages=[{"role": "user", "content": f"Extract invoice data:\n{invoice_text}"}]
)
Approach 3: JSON Mode (OpenAI / Simple Cases)
For OpenAI, JSON mode is a simpler alternative when you just need valid JSON without a specific schema:
from openai import OpenAI
oai_client = OpenAI()
response = oai_client.chat.completions.create(
model="gpt-4o",
response_format={"type": "json_object"}, # Guarantees valid JSON
messages=[{
"role": "user",
"content": "Extract name, email, company from: John Smith, [email protected], Acme Corp"
}]
)
result = json.loads(response.choices[0].message.content)
JSON mode guarantees syntactically valid JSON but not schema compliance. You still need to validate the keys and types yourself.
Approach 4: Outlines (Local Models)
Outlines uses constrained decoding to guarantee the model can only generate tokens that match your schema. For local models, this is the most reliable approach.
import outlines
from pydantic import BaseModel
class Character(BaseModel):
name: str
age: int
occupation: str
# Load a local model
model = outlines.models.transformers("mistralai/Mistral-7B-Instruct-v0.2")
# Create a structured generator — output is GUARANTEED to match the schema
generator = outlines.generate.json(model, Character)
character = generator("Generate a fantasy character")
print(character.name) # guaranteed to exist as a string
print(character.age) # guaranteed to exist as an int
print(character.occupation) # guaranteed to exist as a string
Outlines works by masking invalid tokens at each generation step. The model cannot generate a character that's missing the age field because the tokenizer only allows tokens that lead to valid JSON at that position.
When Outlines makes sense:
- Self-hosted models where you control the inference server
- When you need 100% structural guarantee (medical, legal, financial parsing)
- Batch processing where API costs matter
It doesn't work with API-hosted models — you need access to the logits to apply masks.
Schema Design Patterns
Good schema design makes generation easier and output more reliable.
Use Enums for Constrained Choices
from enum import Enum
from pydantic import BaseModel
class Sentiment(str, Enum):
positive = "positive"
negative = "negative"
neutral = "neutral"
class SentimentAnalysis(BaseModel):
sentiment: Sentiment # Model can only return valid enum values
confidence: float # 0.0 to 1.0
reasoning: str # Chain-of-thought before the classification
# With Instructor, enum validation happens automatically
result = client.messages.create(
model="claude-opus-4-6",
max_tokens=512,
response_model=SentimentAnalysis,
messages=[{"role": "user", "content": f"Analyze sentiment: {text}"}]
)
# result.sentiment is always Sentiment.positive/negative/neutral
Include Reasoning Fields
Add a reasoning or thinking field before the structured output. This improves accuracy because the model reasons before committing to the structured answer.
class ClassificationResult(BaseModel):
thinking: str # Free-form reasoning — filled first
category: str # Final answer — informed by thinking
confidence: float
# The model fills "thinking" first, then uses that reasoning to determine category
# This is structured chain-of-thought
Optional vs Required Fields
from typing import Optional
from pydantic import BaseModel
class PersonInfo(BaseModel):
# Required: model must provide these or fail
name: str
role: str
# Optional: model returns null if not present in source
email: Optional[str] = None
phone: Optional[str] = None
linkedin: Optional[str] = None
Mark fields as Optional when the source text might not contain them. Don't use Optional for fields that should always be there — use required to force extraction.
Streaming Structured Output
For long structured responses, stream the JSON and parse incrementally:
import json
def stream_structured_response(prompt: str, schema_hint: str) -> dict:
"""Stream JSON output and parse when complete."""
buffer = []
with client.messages.stream(
model="claude-opus-4-6",
max_tokens=2048,
messages=[{
"role": "user",
"content": f"{prompt}\n\nRespond with JSON matching: {schema_hint}"
}]
) as stream:
for text in stream.text_stream:
buffer.append(text)
# Optionally: show progress to user while collecting
print(".", end="", flush=True)
full_response = "".join(buffer)
# Extract JSON from potentially wrapped response
start = full_response.find("{")
end = full_response.rfind("}") + 1
if start >= 0 and end > start:
return json.loads(full_response[start:end])
raise ValueError(f"No valid JSON found in response: {full_response[:200]}")
JSON Repair for Legacy Systems
If you can't refactor to use function calling (legacy code, third-party integrations), json-repair library handles many common LLM JSON failures:
from json_repair import repair_json
def robust_json_parse(model_output: str) -> dict:
"""Parse JSON with automatic repair for common LLM failures."""
# Strip markdown code blocks
cleaned = model_output.strip()
if cleaned.startswith("```json"):
cleaned = cleaned[7:]
if cleaned.startswith("```"):
cleaned = cleaned[3:]
if cleaned.endswith("```"):
cleaned = cleaned[:-3]
cleaned = cleaned.strip()
# Try direct parse first
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
# Try repair
repaired = repair_json(cleaned, return_objects=True)
if repaired:
return repaired
raise ValueError(f"Could not parse JSON: {model_output[:200]}")
json-repair handles: trailing commas, missing quotes, single quotes, truncated JSON, extra text before/after. It handles ~80% of real-world LLM JSON failures.
Decision Guide
flowchart TD
A[Need structured output?] --> B{Using API-hosted model?}
B --> |Yes| C{Need schema validation?}
B --> |No - local model| D[Outlines\nConstrained decoding\n100% structural guarantee]
C --> |Simple structure| E{Provider?}
C --> |Complex validation| F[Instructor + Pydantic\nRetry on failure\nType-safe access]
E --> |Anthropic| G[Native tool use\nforce tool_choice]
E --> |OpenAI| H[JSON mode or\nfunction calling]
E --> |Any provider| F
G --> I{Production critical?}
I --> |Yes| F
I --> |Prototype| G
Gotchas
Function calling doesn't validate semantic correctness. The API guarantees the response matches your JSON schema structure. It does not guarantee the extracted name is actually a name, or the email is real. Use Pydantic validators for semantic validation.
Nested schemas increase failure rate. A flat schema with 5 fields is more reliable than a nested schema with 3 levels of nesting. If you have complex nesting, test failure rates carefully and use Instructor's retry logic.
Instructor's retry adds latency. Each retry is a full LLM call. At 3 retries, you could triple your latency for edge cases. Monitor retry rates in production; if they're above 1%, the schema is probably too complex or the prompt isn't clear.
Streaming and structured output conflict. You can't parse JSON while streaming because you don't have the complete string yet. For streaming responses, either accept delay (collect full response, then parse) or use incremental JSON parsers like ijson.
The reasoning field can be gamed. If you add a reasoning field to improve accuracy, models sometimes write the desired final answer in the reasoning field, then copy it to the answer field. This is fine for quality but means the reasoning isn't always genuine exploration.
Key Takeaways:
- "Return JSON" prompts have 1-5% failure rates. For production reliability, use function calling or Instructor.
- Instructor + Pydantic is the pragmatic choice for API-hosted models: it wraps any provider, validates output, and retries automatically.
- Add a
reasoningorthinkingfield to structured schemas to improve accuracy via implicit chain-of-thought before the structured answer."Structured output is not about forcing the model to format nicely. It's about making downstream code not crash."
Interview Questions
Q: Design a pipeline that extracts structured data from 10,000 customer support emails daily. How do you ensure reliability and handle failures?
The core problem is extraction reliability at scale. At 10K emails/day, a 1% failure rate is 100 failures/day — too many to manually fix.
I'd build around Instructor + Pydantic. Define the extraction schema as a Pydantic model with validators: email format, enum values for categories, required fields. Instructor wraps the LLM call and automatically retries with error feedback when validation fails.
For the schema, I'd use a flat structure where possible (flat schemas fail less often than deeply nested ones), include a confidence field (the model estimates its own extraction confidence), and use enums for constrained fields like category and priority.
Error handling strategy: Instructor retries up to 3 times. After 3 failures, the email goes to a dead-letter queue for human review. I'd track retry rates by email type — if specific email patterns have high retry rates, the schema or prompt needs refinement.
Monitoring: track extraction success rate, average retries per extraction, fields most often causing validation failures. A dashboard showing these metrics lets me proactively fix the prompt before failures spike.
Quick-fire Questions
| Question | Answer |
|---|---|
| What is Instructor? | A Python library that wraps LLM providers with Pydantic validation and automatic retry for structured output |
| What does function calling guarantee? | Structural schema compliance (correct keys, types); does not guarantee semantic correctness |
| What is Outlines? | A library that uses constrained decoding to guarantee valid structured output from local models |
| What is JSON mode? | An OpenAI API parameter that guarantees syntactically valid JSON (no schema validation) |
| What is the failure rate of "return JSON" prompts? | 1-5% per call; acceptable for prototypes, unacceptable for production pipelines |
| Why add a reasoning field to structured schemas? | Improves accuracy by giving the model space to reason before committing to the structured answer |