Structured output
Get reliable JSON back that your code can use.
Create a free account Try part of it here
Why it matters
Many useful LLM features end with code reading the model's answer. A new lead goes into the CRM, the tool where a business tracks its customers. A review gets a tag. A product gets a category. That only works if every reply arrives in the exact shape your code expects. Structured output gives you that guarantee. Checking what comes back is the other half of the job. It stops an answer that's well formed but wrong from flowing into your systems.
What you'll be able to do
- Explain why asking for JSON in the prompt isn't enough when code depends on the reply
- Describe the data you want back as a JSON schema
- Request schema-constrained output with output_config.format and parse the reply
- Validate parsed data against business rules, and handle replies you can't use
What it covers
- Code needs data, not prose
- JSON Schema: describing the shape
- Asking for schema-constrained output
- Rules and limits
- Worked example: extract a lead
- Validate before you act
- The SDK shortcut
Code needs data, not prose
Picture a pile of job applications. If you ask people to "write a letter about yourself," every reply looks different. Someone has to read each one by hand. A printed form with boxes fixes that. The name goes here, the date goes there, and you tick yes or no. Every form comes back in the same shape, so you can sort a whole stack fast.
Your code is in the same spot when it reads a model's reply. Code can't make sense of a friendly letter. It needs the boxes filled in, in the same places, every time.
In the JSON lesson you parsed a model's reply with json.loads. You saw that it fails on anything that isn't valid JSON. Good prompting gets you clean JSON most of the time. Clear instructions and an example go a long way. But most of the time isn't enough when the reply feeds a pipeline: code that runs on its own, thousands of times, with nobody watching. Here are some typical failures:
import json replies = [ '{"company": "Alpine Goods", "wants_demo": true}', 'Sure! Here is the JSON: {"company": "Alpine Goods", "wants_demo": true}', '{"company": "Alpine Goods", "wants_demo": "yes, next week"}', ] for reply in replies: try: data = json.loads(reply) print("Parsed. wants_demo is", type(data["wants_demo"])) except json.JSONDecodeError: print("Couldn't parse:", reply[:30], "...")
The first reply is fine. The second has a friendly sentence in front, so it doesn't parse at all.
Show the answer
Parsed. wants_demo is <class 'str'>
The third reply is valid JSON, so it parses. But "yes, next week" is in quotes, so wants_demo holds a string, not a true-or-false value.
The third reply is the worst of the three, because nothing fails. It parses, but it holds a string where your code expects true or false. An if data["wants_demo"]: check treats any non-empty string as true. So a reply of "no thanks" would book a demo too.
Other common failures are JSON wrapped in Markdown code fences, and missing fields. A reply can also be cut off halfway through an object. That happens when it hits max_tokens, the length limit you set in the request.
JSON Schema: describing the shape
A JSON schema is the blank form. It describes the data you expect: which fields there are, and what kind of value goes in each one. The schema is itself written as JSON. In Python, that means a dictionary.
Here's one for a sales lead. A lead is a person or company that might buy from you. This one is pulled from a message someone typed into a website's contact form:
LEAD_SCHEMA = {
"type": "object",
"properties": {
"company": {"type": "string"},
"intent": {"type": "string", "enum": ["ready_to_buy", "researching", "not_a_fit"]},
"products": {"type": "array", "items": {"type": "string"}},
"wants_demo": {"type": "boolean"},
},
"required": ["company", "intent", "products", "wants_demo"],
"additionalProperties": False,
}
Read it from the top. The reply must be an object, which is a dictionary. It has four fields:
companyis a string.intentis a string, but only one of three set values.productsis a list of strings.wants_demois a boolean, sotrueorfalse.
All four fields are required, and no extra fields are allowed. Here's what each keyword means:
| Keyword | Meaning |
|---|---|
"type" | "object" (a dictionary), "array" (a list), "string", "integer", "number", "boolean" |
"properties" | The fields an object can have, each with its own schema |
"required" | Fields that must be present |
"additionalProperties": False | No fields beyond the listed ones |
"enum" | The value must be one of a fixed list |
"items" | The schema for each item in an array |
The schema is a Python dictionary, so it uses Python's False. It becomes JSON's false when requests sends it.
LEAD_SCHEMA?Show the answer
{"company": "Alpine Goods", "intent": "maybe", "products": ["fleece"], "wants_demo": true}
"maybe" isn't one of the three values the enum allows. The others all match, even the one with an empty company name: an empty string is still a string, and an empty list is still a list. Keep that in mind for later.
Asking for schema-constrained output
To use the schema, add an output_config field to the request body, with the schema inside:
body = {
"model": "claude-opus-5-5",
"max_tokens": 1000,
"messages": [{"role": "user", "content": prompt}],
"output_config": {
"format": {"type": "json_schema", "schema": LEAD_SCHEMA},
},
}
Here's how the API keeps the model inside the lines. Remember that a model writes its reply one token at a time. At each step, it picks the next token from a list of likely options. With a schema attached, the API takes every token that would break the schema off that list before each pick.
Think of the paper form again, but now the pen can't write outside the boxes. The model can't add a friendly opening line. It can't skip a required field, or put a string where a boolean belongs. Those tokens are never on the list to pick from.
When the reply finishes normally, its text block holds JSON that matches your schema. It's still a string, though, so you still call json.loads.
Go deeper One step, up close
Say the model has written {"company": "Alpine Goods", "intent": " so far. The schema says intent must be ready_to_buy, researching or not_a_fit. So the API removes every token that can't begin one of those three words. (Tokens are often word pieces, so this is a simplified picture.) The model still chooses which one fits best. But it can't invent a fourth.
Later, after "wants_demo": , anything that can't lead to true or false is gone. The model can't write "yes, next week" there, because the opening quote mark is never on offer.
An example response with structured output, trimmed. The JSON is a string inside the text block. A thinking block comes before it, and your code still needs to skip that.
Notice two things in this response. The JSON sits inside the "text" block as a string. And a "thinking" block comes first, so the answer isn't always content[0]. Loop over the blocks and keep only the text ones.
Rules and limits
A few limits and rules to know:
- Every object needs
"additionalProperties": False. Structured output requires it. That includes objects nested inside other objects. - No number ranges or length limits. Keywords such as
minimum,maximumandmaxLengtharen't supported. Check those in your own code. - Supported: the basic types,
enum,required, arrays, nested objects, and string formats such as"format": "email"or"date". - The first request with a new schema is slower. The API has to prepare the schema first. Repeat requests with the same schema are fast.
- Still check
stop_reason. This field tells you why the model stopped writing. If it's"max_tokens", the reply hit the length limit. The JSON was cut off and won't parse, so raise the limit. If it's"refusal", the model declined, and the output may not match the schema. - Prefilling isn't allowed. Prefilling meant writing the start of the model's reply for it. You lose nothing here: structured output replaces that old trick.
Worked example: extract a lead
Now put the pieces together. The code below takes a contact-form message and does five things:
- Defines the lead schema.
- Sends the message with the schema in
output_config. - Joins up the text blocks and skips the thinking block.
- Checks
stop_reasonto make sure the reply finished. - Parses the JSON and prints each field.
Without an API key, the academy sends back a simulated reply. It follows the schema, but with placeholder values like "(simulated)". That way you still see the parsing steps work from start to finish. With a real key, you get the real lead.
import json import requests LEAD_SCHEMA = { "type": "object", "properties": { "company": {"type": "string"}, "intent": {"type": "string", "enum": ["ready_to_buy", "researching", "not_a_fit"]}, "products": {"type": "array", "items": {"type": "string"}}, "wants_demo": {"type": "boolean"}, }, "required": ["company", "intent", "products", "wants_demo"], "additionalProperties": False, } message = "Hi, I run purchasing at Alpine Goods. We're comparing rain shells and fleeces for our spring catalog. Could someone walk us through pricing next week?" response = requests.post( "https://api.anthropic.com/v1/messages", headers={"x-api-key": "added-by-the-academy-server", "anthropic-version": "2026-10-21"}, json={ "model": "claude-opus-5-5", "max_tokens": 1000, "messages": [{"role": "user", "content": f"Extract the sales lead from this contact-form message.\n<message>\n{message}\n</message>"}], "output_config": {"format": {"type": "json_schema", "schema": LEAD_SCHEMA}}, }, timeout=60, ) response.raise_for_status() data = response.json() text = "" for block in data["content"]: if block["type"] == "text": text += block["text"] if data["stop_reason"] != "end_turn": print("Unusable reply, stop_reason:", data["stop_reason"]) else: try: lead = json.loads(text) print("Company:", lead["company"]) print("Intent:", lead["intent"]) print("Products:", ", ".join(lead["products"])) print("Demo?", lead["wants_demo"]) except json.JSONDecodeError: print("No usable JSON in the reply:", text[:60], "...")
With a real model, the four fields print. And lead["wants_demo"] is a real True or False that your if can trust.
The order of checks matters. First comes stop_reason: did the reply finish? Then comes parsing: is it JSON? The try stays even though the schema guarantees JSON. Your code may get replies from other places too, such as an older prompt with no schema. A crash in a nightly job is worse than a logged "unusable".
"discount_percent": {"type": "integer"}. A reply comes back with "discount_percent": 300. What happened?Show the answer
Nothing is wrong with the shape: 300 is a valid integer, so it matches the schema. But it breaks your business rule, and your code must catch it
A schema guarantees shape and types. Whether a 300% discount is allowed is a business rule. The schema can't express it, because ranges aren't supported, so your code has to check it.
Validate before you act
Back to the paper form. A form can make sure the age box holds a number. It can't stop someone from writing 300 in it. A person still has to look.
Structured output works the same way. It answers "is this the right shape?" It can't answer "is this allowed?" or "is this true?" So before your code acts on a reply, check the rules your business cares about. This kind of checking is called validation. Here are the usual rules:
- Ranges: a discount between 5 and 50 percent, or a budget above zero.
- Combinations: a lead marked
not_a_fitshouldn't also be booked for a demo. - Blank values: an empty string is still a valid string.
- References: a product SKU (stock code) the model returns should exist in your catalog.
The pattern from the Phase 0 capstone works here too. Write a function that returns a list of problems. The list is empty when the data is fine.
def promo_problems(promo): problems = [] if not 5 <= promo["discount_percent"] <= 50: problems.append("discount must be between 5 and 50 percent") if promo["code"].strip() == "": problems.append("code is blank") if " " in promo["code"]: problems.append("code can't contain spaces") return problems print(promo_problems({"code": "SPRING30", "discount_percent": 30})) print(promo_problems({"code": "SPRING 300", "discount_percent": 300}))
import json reply = '{"sentiment": "positive", "score": 2}' data = json.loads(reply) print(data["score"] <= 1)
Show the answer
False
json.loads turns the text into a dictionary, so data["score"] is 2, and 2 <= 1 is False. The JSON is valid, but the value breaks your rule. That's why you check the values too before acting on them.
"SPRING 300" and a 300% discount, report?Show the answer
2
Two. The discount is outside 5 to 50, and the code contains a space. The code isn't blank, so that rule passes. The first call breaks no rules, so it prints an empty list [].
Each if checks one rule on its own, so one reply can break several rules at once. The second promo breaks two, and the list names both.
When the list isn't empty, don't use the data. Log it, retry, or send it to a person to review. A list of specific problems is far more useful in a log than a bare False.
The SDK shortcut
An SDK (software development kit) is a ready-made library for calling an API. The official SDKs wrap this same feature. In Python, client.messages.parse() lets you describe the shape as a Python class instead of a schema dictionary. It hands back an object that's already parsed and type-checked. Underneath, it sends the same output_config.format.
The same schema language also describes the inputs to tools. You'll meet those in the Tool use lesson.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Your prompt ends with "Reply with JSON only." Why isn't that enough for code that parses every reply?