This Claude structured outputs tutorial shows how to get strictly typed JSON out of Claude without begging the model to format correctly. We’ll use Anthropic’s tool-use primitive to force a schema, then validate the result with Pydantic so your agent can trust the payload.
Prerequisites
- Python 3.10 or newer
anthropicandpydanticinstalled (pip install anthropic pydantic)- An Anthropic API key exported as
ANTHROPIC_API_KEY - Basic familiarity with JSON Schema
If you route through a gateway, the same schemas apply; we’ll note that at the end.
Step 1: Define the target schema
Claude doesn’t have a native “JSON mode” toggle like OpenAI’s response_format. The reliable path is to define a tool whose input_schema is your desired shape, then force the model to call it. Start by describing the data you want.
from pydantic import BaseModel, Field
from typing import List
class LineItem(BaseModel):
sku: str
quantity: int = Field(ge=1)
unit_price_cents: int = Field(ge=0)
class Invoice(BaseModel):
invoice_id: str
vendor: str
total_cents: int
items: List[LineItem]
We’ll convert this to JSON Schema for Anthropic. Pydantic v2 exposes model_json_schema().
schema = Invoice.model_json_schema()
print(schema)
Expected output (truncated for brevity):
{
"properties": {
"invoice_id": {"title": "Invoice Id", "type": "string"},
"vendor": {"title": "Vendor", "type": "string"},
"total_cents": {"title": "Total Cents", "type": "integer"},
"items": {
"items": {"$ref": "#/$defs/LineItem"},
"title": "Items",
"type": "array"
}
},
"$defs": {
"LineItem": {
"properties": {
"sku": {"title": "Sku", "type": "string"},
"quantity": {"minimum": 1, "title": "Quantity", "type": "integer"},
"unit_price_cents": {"minimum": 0, "title": "Unit Price Cents", "type": "integer"}
},
"required": ["sku", "quantity", "unit_price_cents"],
"title": "LineItem",
"type": "object"
}
},
"required": ["invoice_id", "vendor", "total_cents", "items"],
"title": "Invoice",
"type": "object"
}
Step 2: Register the schema as a Claude tool
Anthropic expects a tools list where each entry has name, description, and input_schema. The input_schema is a standard JSON Schema object.
tool = {
"name": "extract_invoice",
"description": "Extract structured invoice data from free text",
"input_schema": schema,
}
tools = [tool]
Step 3: Force the call
Set tool_choice to pin the model to your tool. Without this, Claude may return plain text. With it, the response will contain a tool_use block whose input is the parsed object.
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
tools=tools,
tool_choice={"type": "tool", "name": "extract_invoice"},
messages=[
{
"role": "user",
"content": "Invoice #INV-42 from Acme Corp: 2x SKU-100 at $5.00, 1x SKU-200 at $12.00. Total $22.00.",
}
],
)
print(response.stop_reason)
Expected output:
tool_use
Step 4: Parse and validate
The assistant message contains a tool_use block. Extract its input and load it back into Pydantic.
tool_block = next(b for b in response.content if b.type == "tool_use")
raw = tool_block.input
invoice = Invoice(**raw)
print(invoice.model_dump_json(indent=2))
Expected output:
{
"invoice_id": "INV-42",
"vendor": "Acme Corp",
"total_cents": 2200,
"items": [
{"sku": "SKU-100", "quantity": 2, "unit_price_cents": 500},
{"sku": "SKU-200", "quantity": 1, "unit_price_cents": 1200}
]
}
If Claude hallucinates a missing field or wrong type, Pydantic raises ValidationError. That’s your signal to retry or fall back.
Step 5: Handle real-world messiness
Production text isn’t clean. You’ll get partial invoices, conflicting totals, or currency symbols. Two patterns keep this robust:
- Retry on validation failure with the error message fed back as a user turn.
- Relax constraints in the schema (e.g.,
total_centsasint | None) and compute derived fields yourself.
Example retry loop:
from pydantic import ValidationError
def extract(text: str) -> Invoice:
msg = [{"role": "user", "content": text}]
for _ in range(3):
resp = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
tools=tools,
tool_choice={"type": "tool", "name": "extract_invoice"},
messages=msg,
)
block = next(b for b in resp.content if b.type == "tool_use")
try:
return Invoice(**block.input)
except ValidationError as e:
msg.append({"role": "assistant", "content": resp.content})
msg.append({
"role": "user",
"content": f"Validation failed: {e}\nFix the extraction to match the schema.",
})
raise RuntimeError("extraction failed after retries")
Advanced: Nested arrays and enums
Tool-use schemas accept the full JSON Schema draft-07 subset Anthropic supports. Add enums for constrained strings, or deeper nesting for line-item metadata.
from pydantic import BaseModel, Field
from typing import Literal
class LineItem(BaseModel):
sku: str
quantity: int = Field(ge=1)
unit_price_cents: int = Field(ge=0)
tax_status: Literal["taxable", "exempt"] = "taxable"
# regenerate schema, same tool registration
Claude respects enum/Literal closely when the tool is forced. In testing, mismatch rates drop to near zero compared to free-form “output JSON” prompts.
Routing through an OpenAI-compatible gateway
If you don’t want to wire Anthropic directly, an OpenAI-compatible endpoint such as n4n.ai accepts the same shape as a function and forwards provider cache-control hints, so you keep prompt caching while using one client. The tool_choice equivalent is function_call: {"name": "extract_invoice"} on the chat completions route. The schema stays identical; only the transport changes.
Takeaways
- Claude structured outputs tutorial outcome: tool use is the only deterministic way to get JSON from Claude today.
- Define schema once with Pydantic, reuse for both Anthropic
input_schemaand validation. - Force
tool_choiceto avoid text drift. - Validate immediately; retry with the error as context.
- For multi-model fleets, the schema ports cleanly to OpenAI-compatible gateways.
That’s the whole pipeline. Ship it behind a queue and you have a typed extraction worker.