Step 05 of 18
Structured output
Get schema-valid JSON from a model every time with JSON Schema, structured outputs, Pydantic or Zod, plus validation and retries.
Most AI features don't show the model's words to a human. They feed them into code: a ticket router needs a category, an invoice pipeline needs numbers, a UI needs fields to render. Code needs structured output, data in an exact shape, every time. This lesson shows why asking politely for JSON isn't enough, how schema-constrained output works, how to get typed objects with Pydantic (Python) and Zod (TypeScript), how to design schemas a model fills well, and how to handle the cases where valid JSON is still wrong.
Why "reply in JSON" breaks
The obvious first attempt is to ask for JSON in the prompt:
Extract the customer's name, email and plan from this message. Reply in JSON.It works most of the time. In production, "most of the time" means a steady trickle of failures like these:
Sure! Here's the extracted information:
{"name": "Jane Doe", "email": "[email protected]", "plan": "Enterprise",}
Let me know if you need anything else!That one reply has two separate problems: chatty text around the data, and a trailing comma that makes it invalid JSON. Other common failures are JSON wrapped in a markdown code fence, a missing field, a renamed key (customer_name), the wrong type ("plan": ["Enterprise"]), an invented extra field, and output truncated at the token limit. Each needs its own defensive parsing code, and you'll never catch them all.
The fix is to stop asking for a format and start enforcing one.
Structured outputs: schema-constrained generation
With structured outputs, you send a JSON Schema alongside the request, and the API constrains generation so the model can only produce tokens that keep the output valid against that schema. The result is guaranteed to parse, with the required fields present and the right types. Many providers offer this; on Claude it is the output_config.format parameter:
import json
import anthropic
client = anthropic.Anthropic()
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"plan": {"type": "string", "enum": ["Free", "Pro", "Enterprise"]},
"demo_requested": {"type": "boolean"},
},
"required": ["name", "email", "plan", "demo_requested"],
"additionalProperties": False,
}
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": "Extract the contact details: Jane Doe ([email protected]) wants Enterprise and asked for a demo.",
}],
output_config={"format": {"type": "json_schema", "schema": schema}},
)
if response.stop_reason != "end_turn":
raise RuntimeError(f"No usable output: stop_reason={response.stop_reason}")
text = next(block.text for block in response.content if block.type == "text")
data = json.loads(text)
print(data){'name': 'Jane Doe', 'email': '[email protected]', 'plan': 'Enterprise', 'demo_requested': True}The text block contains only the JSON, with no preamble and no fences. A quick JSON Schema primer covers almost everything you'll need:
| Keyword | Meaning |
|---|---|
"type" | object, array, string, integer, number, boolean or null |
"properties" | The fields of an object and their schemas |
"required" | Fields that must be present |
"additionalProperties": false | No other fields allowed. Structured outputs require this on every object |
"enum" | The value must be one of a fixed list |
"items" | The schema for each element of an array |
"anyOf" | One of several schemas, e.g. a string or null for an optional value |
"format" | String formats such as date, date-time, email, uri, uuid |
The supported subset has limits. Recursive schemas aren't allowed, and numeric and length constraints (minimum, maximum, minLength and similar) aren't enforced during generation. The SDK helpers below deal with that for you.
Typed results with Pydantic
Hand-writing JSON Schema is tedious and drifts from your code. In Python, define the shape once as a Pydantic model and let the SDK convert it. client.messages.parse() sends the schema and returns a validated instance:
from typing import Literal
import anthropic
from pydantic import BaseModel, Field
client = anthropic.Anthropic()
class Ticket(BaseModel):
category: Literal["bug", "billing", "feature_request", "other"]
severity: int = Field(ge=1, le=4, description="1 = cosmetic, 4 = outage for many users")
summary: str = Field(description="One sentence, in plain English")
order_id: str | None = Field(description="Order ID if the message mentions one, otherwise null")
response = client.messages.parse(
model="claude-sonnet-5-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": "Order #A-1042 was charged twice and now the checkout page shows a 500 error.",
}],
output_format=Ticket,
)
ticket = response.parsed_output # a Ticket instance
print(ticket)category='billing' severity=3 summary='The customer was double-charged for an order and checkout now returns a server error.' order_id='A-1042'Literal[...] becomes an enum, str | None becomes anyOf string-or-null, and Field(description=...) puts the description into the schema, where the model reads it. Constraints that structured outputs can't enforce, like ge=1, le=4, are moved into the field description as a hint for the model, then checked client-side when the result is validated. If validation fails (the model said severity: 7, or a refusal produced text that isn't JSON), parse() raises pydantic.ValidationError.
For production code, you want to see the stop_reason before validation runs. Build the schema with anthropic.transform_schema (the same conversion parse() uses), call create(), check the stop reason, then validate:
import pydantic
def extract_ticket(message: str) -> Ticket | None:
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": message}],
output_config={"format": {"type": "json_schema", "schema": anthropic.transform_schema(Ticket)}},
)
if response.stop_reason == "refusal":
return None # route to a human instead
if response.stop_reason == "max_tokens":
raise RuntimeError("Output truncated; raise max_tokens")
text = next(block.text for block in response.content if block.type == "text")
try:
return Ticket.model_validate_json(text)
except pydantic.ValidationError as e:
raise RuntimeError(f"Schema-valid JSON broke a constraint: {e}") from eThe same in TypeScript
In TypeScript, define the shape with Zod and convert it with the SDK's zodOutputFormat helper. client.messages.parse() returns parsed_output directly. For the same check-then-validate control, pass the format to create() and validate with safeParse:
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
const client = new Anthropic();
const Ticket = z.object({
category: z.enum(["bug", "billing", "feature_request", "other"]),
severity: z.number().int().min(1).max(4).describe("1 = cosmetic, 4 = outage for many users"),
summary: z.string().describe("One sentence, in plain English"),
order_id: z.string().nullable().describe("Order ID if the message mentions one, otherwise null"),
});
type Ticket = z.infer<typeof Ticket>;
async function extractTicket(message: string): Promise<Ticket | null> {
const response = await client.messages.create({
model: "claude-sonnet-5-5",
max_tokens: 1024,
messages: [{ role: "user", content: message }],
output_config: { format: zodOutputFormat(Ticket) },
});
if (response.stop_reason === "refusal") return null;
if (response.stop_reason === "max_tokens") throw new Error("Output truncated; raise max_tokens");
const text = response.content.find((b): b is Anthropic.TextBlock => b.type === "text")?.text ?? "";
const result = Ticket.safeParse(JSON.parse(text));
if (!result.success) throw new Error(`Constraint violated: ${result.error.message}`);
return result.data;
}Designing schemas the model fills well
The schema is part of your prompt. The model reads the field names, descriptions and enums, so design them for comprehension as well as for your code.
Use enums for anything categorical. "category": {"enum": [...]} makes a typo or an invented category impossible, and it doubles as documentation of the allowed values.
Make "unknown" representable. If due_date is a required string, the model must produce a date, so when the text has none it will invent a plausible one. A nullable field (str | None) with the description "null if no due date is stated" gives the model an honest answer. This is the single most important schema-design rule for extraction.
Describe fields precisely. "amount" is ambiguous. "amount_cents": integer, "Total in the smallest currency unit, e.g. 1999 for $19.99" is not. Put units, formats and edge-case rules in descriptions.
Keep names meaningful and structures shallow. customer_email beats field3. Deeply nested objects are harder to fill correctly than a flat record with a list or two.
Ask for evidence when you need to trust a value. Adding a field like "evidence": "the exact sentence from the input that supports the category" makes the output auditable, and lets your code check that the quote really appears in the input.
Still write a prompt. The schema says what shape; the prompt says what task. "Extract the ticket details. Severity reflects business impact, not how angry the customer sounds" carries judgement that no schema can express.
Valid is not the same as correct
Structured outputs guarantee syntax: the JSON parses and matches the schema. They don't guarantee semantics. A perfectly valid invoice can have line items that don't add up to the total, a date in the wrong year, or a name the model misread. Layer your checks:
- Schema validation, handled by the API plus Pydantic or Zod: types, enums, required fields, numeric ranges.
- Business rules in your own code: totals add up, dates fall in a sensible range, IDs exist in your database.
- Grounding checks: quoted evidence appears in the source text, and extracted emails occur in the input.
When a check fails, you have three options. You can retry with feedback: send a follow-up turn that includes the model's previous output and the specific error ("line items sum to 4,200 but total_cents is 4,500; re-check the total"). Models are good at fixing a concrete, named mistake. You can fall back, for example to a more capable model. Or you can escalate the case to a human queue. Cap retries at one or two. A model that fails the same check three times needs a better prompt, not a fourth attempt.
Two practical notes. The first request with a new schema has extra latency while the API compiles it; it is cached for subsequent requests. And structured outputs are about the model's final answer. When the JSON is really the arguments to an action your code will take, strict tool use (strict: true on a tool definition) gives the same schema guarantee for tool inputs. The next lesson covers it.
Practice
Build an invoice extractor. Given the raw text of an invoice, return:
vendor(string),invoice_number(string),issue_date(ISO date string) anddue_date(ISO date or null);currency(one ofUSD,EUR,GBP);line_items: a list of{description, quantity, unit_price_cents};total_cents(integer).
Then add a business-rule check that the line items sum to the total, and retry once with feedback if they don't. Test it on this text:
INVOICE #2026-118 from Brightline Studio. Issued 3 Oct 2026.
2 x Logo concepts @ $450.00
1 x Brand guidelines PDF @ $1,200.00
Total due: $2,100.00 (USD). Payment terms: on receipt.Show solution
from datetime import date
from typing import Literal
import anthropic
import pydantic
from anthropic.types import MessageParam
from pydantic import BaseModel, Field
client = anthropic.Anthropic()
class LineItem(BaseModel):
description: str
quantity: int
unit_price_cents: int = Field(description="Price per unit in cents, e.g. 45000 for $450.00")
class Invoice(BaseModel):
vendor: str
invoice_number: str = Field(description="As printed, without a leading #")
issue_date: date
due_date: date | None = Field(description="Null unless a specific due date is printed")
currency: Literal["USD", "EUR", "GBP"]
line_items: list[LineItem]
total_cents: int = Field(description="The invoice total in cents, as printed")
SYSTEM = "You extract invoice data exactly as printed. Never compute values that aren't printed."
SCHEMA = anthropic.transform_schema(Invoice)
def business_errors(inv: Invoice) -> list[str]:
line_sum = sum(item.quantity * item.unit_price_cents for item in inv.line_items)
if line_sum != inv.total_cents:
return [f"line items sum to {line_sum} cents but total_cents is {inv.total_cents}"]
return []
def extract_invoice(raw: str) -> Invoice:
messages: list[MessageParam] = [{"role": "user", "content": f"<invoice>\n{raw}\n</invoice>"}]
errors: list[str] = []
for _attempt in range(2):
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=2048,
system=SYSTEM,
messages=messages,
output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
)
if response.stop_reason != "end_turn":
raise RuntimeError(f"stop_reason={response.stop_reason}")
text = next(b.text for b in response.content if b.type == "text")
try:
invoice = Invoice.model_validate_json(text)
except pydantic.ValidationError as e:
errors = [str(e)]
else:
errors = business_errors(invoice)
if not errors:
return invoice
messages += [
{"role": "assistant", "content": response.content},
{"role": "user", "content": "Your extraction failed these checks: " + "; ".join(errors)
+ ". Re-read the invoice and return corrected data."},
]
raise RuntimeError(f"Extraction failed after retry: {errors}")
raw = """INVOICE #2026-118 from Brightline Studio. Issued 3 Oct 2026.
2 x Logo concepts @ $450.00
1 x Brand guidelines PDF @ $1,200.00
Total due: $2,100.00 (USD). Payment terms: on receipt."""
print(extract_invoice(raw).model_dump_json(indent=2)){
"vendor": "Brightline Studio",
"invoice_number": "2026-118",
"issue_date": "2026-10-03",
"due_date": null,
"currency": "USD",
"line_items": [
{
"description": "Logo concepts",
"quantity": 2,
"unit_price_cents": 45000
},
{
"description": "Brand guidelines PDF",
"quantity": 1,
"unit_price_cents": 120000
}
],
"total_cents": 210000
}Key decisions: due_date is nullable because "on receipt" is not a date, so the model has no reason to invent one. Money is integer cents with a description showing the conversion. Pydantic's date type maps to the schema's date format and parses into a real date object. The retry appends the model's own previous answer plus the specific failed check, which gives it something concrete to fix.
Key takeaways
- Prompt-only JSON fails in many small ways. Structured outputs constrain generation to your JSON Schema, so the result always parses and fits the schema.
- On Claude, pass
output_config={"format": {"type": "json_schema", "schema": ...}}, or usemessages.parse()with a Pydantic model (Python) orzodOutputFormat(TypeScript). - Always check
stop_reasonfirst. A refusal or truncation voids the schema guarantee. - Design schemas for the model: enums for categories, nullable fields for "not stated", precise descriptions with units, and shallow structures.
- Schema-valid isn't correct. Add business-rule and grounding checks, retry once with specific feedback, then escalate.
Check yourself
Quiz
0/4 correct · 4 left
1.You extract due_date as a required string. For invoices that say "payment on receipt", the model returns plausible but made-up dates. What is the best fix?
2.With structured outputs enabled, which of these can still happen?
3.Why should you check stop_reason before parsing a structured response?
4.Your Pydantic model has severity: int = Field(ge=1, le=4). Structured outputs can't enforce minimum and maximum during generation. How does the Python SDK handle this?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.