Claude Structured Outputs JSON Schema: Python Examples
Get schema-valid JSON from Claude with output_config.format. Python example, supported schema features, limits, and what a rejected schema returns.

To get schema-valid JSON from Claude, pass a JSON Schema in output_config.format with "type": "json_schema". Claude's response is then constrained to that schema, so the text block parses with json.loads() and has the fields and types you declared. No retry loop for malformed JSON.
The catch is that the schema has to stay inside a supported subset. If it doesn't, the API answers with a 400 error and details. Two other cases can still hand you output that doesn't match: a refusal and a truncated response. Both are covered below.
The smallest working example#
Install the SDK in a virtual environment, set ANTHROPIC_API_KEY, and run this. It's the extraction example from Anthropic's structured outputs docs.
import json
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[
{
"role": "user",
"content": (
"Extract the key information from this email: "
"John Smith ([email protected]) is interested in our Enterprise plan "
"and wants to schedule a demo for next Tuesday at 2pm."
),
}
],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"plan_interest": {"type": "string"},
"demo_requested": {"type": "boolean"},
},
"required": ["name", "email", "plan_interest", "demo_requested"],
"additionalProperties": False,
},
}
},
)
text = next(block.text for block in response.content if block.type == "text")
contact = json.loads(text)
print(contact)
The docs show this printing a dict with name, email, plan_interest and demo_requested.
Two details matter here. The schema goes under output_config.format, not at the top level of the request. And you read the result from the first text block of response.content, then parse it yourself. Don't assume content[0] is text; filtering by block type is safer.
What the JSON schema is doing#
A few of the related questions people ask belong here.
What is JSON Schema? It's a JSON document that describes the shape of other JSON: which keys exist, which are required, and what type each value has. What is schema structured data? Same idea, data whose shape is declared up front so a program can rely on it. Is JSON structured data? Yes, once a schema pins down its keys and types. Free-form JSON is only syntactically structured.
What's the structure of a JSON file? An object ({}) holds key/value pairs, an array ([]) holds ordered values, and values are strings, numbers, booleans, null, objects or arrays. The schema above says: an object with four named properties, all required, nothing else allowed.
The constrained decoding behind structured outputs is what makes the guarantee. According to the docs, you get always-valid JSON, guaranteed field types and required fields, and no retries for schema violations.
Supported schema features#
The supported set is standard JSON Schema with limits. This is the part to check before you paste in a schema generated from some other system.
| Works | Doesn't work |
|---|---|
object, array, string, integer, number, boolean, null |
Recursive schemas |
enum (strings, numbers, bools, nulls only) and const |
Complex types inside enum |
anyOf and allOf (allOf with $ref is not supported) |
External $ref, such as http://... |
$ref, $def, definitions (internal only) |
minimum, maximum, multipleOf |
default |
minLength, maxLength |
required, and additionalProperties set to false |
additionalProperties set to anything but false |
String formats: date-time, time, date, duration, email, hostname, uri, ipv4, ipv6, uuid |
Array constraints beyond minItems of 0 or 1 |
minItems of 0 or 1 |
The rule that trips people most: every object needs "additionalProperties": false. In Python that's False, capitalized.
The docs mention a transform_schema() helper in the anthropic package. It moves unsupported constraints into field descriptions, accepts a Pydantic model, and returns the transformed schema instead of sending it. If your schema comes from Pydantic, start there.
What happens when the schema is rejected#
Use an unsupported feature and the API returns a 400 error with details about what it didn't like. That's the whole contract in Anthropic's docs: a 400, with a message pointing at the problem. Nothing is generated, so there's no output to parse.
The second way to get rejected is size. The schema is compiled into a grammar, and bigger schemas compile slower, so the API enforces limits on every request that uses output_config.format or strict: true:
| Limit | Value |
|---|---|
| Strict tools per request | 20 |
Optional parameters (anything not in required), across all strict tool schemas and JSON output schemas |
24 |
Parameters with union types (anyOf or type arrays like ["string", "null"]) |
16 |
Union types are called out as especially expensive because they create exponential compilation cost. The docs don't say here which error a breach returns, so treat it as a request failure and shrink the schema.
My advice: catch these before the network call. This script flags the unsupported keywords from the table, a non-false additionalProperties, and the 24 optional parameter ceiling. It's a rough preflight, not a full validator.
BAD_KEYS = ("minimum", "maximum", "multipleOf", "minLength", "maxLength")
def problems(schema, path="$"):
found = []
if isinstance(schema, list):
for i, item in enumerate(schema):
found += problems(item, f"{path}[{i}]")
return found
if not isinstance(schema, dict):
return found
for key in BAD_KEYS:
if key in schema:
found.append(f"{path}: {key} is not supported")
if schema.get("type") == "object" and schema.get("additionalProperties") is not False:
found.append(f"{path}: additionalProperties must be false")
if schema.get("minItems", 0) not in (0, 1):
found.append(f"{path}: minItems only supports 0 or 1")
ref = schema.get("$ref")
if isinstance(ref, str) and not ref.startswith("#"):
found.append(f"{path}: external $ref is not supported")
for key, value in schema.items():
if key == "properties" and isinstance(value, dict):
for name, sub in value.items():
found += problems(sub, f"{path}.{name}")
elif key != "properties":
found += problems(value, f"{path}.{key}")
return found
def optional_count(schema):
props = schema.get("properties", {})
return len([p for p in props if p not in schema.get("required", [])])
schema = {
"type": "object",
"properties": {
"name": {"type": "string", "maxLength": 40},
"age": {"type": "integer", "minimum": 0},
"tags": {"type": "array", "items": {"type": "string"}, "minItems": 3},
},
"required": ["name"],
}
for p in problems(schema):
print(p)
print("optional parameters:", optional_count(schema), "(limit 24)")
When the output still doesn't match#
A valid request can still return something that doesn't fit your schema. The docs name three cases.
Refusals. If Claude declines for safety reasons, stop_reason is "refusal", the status is 200, you're billed for the tokens, and the output may not match the schema because the refusal message takes precedence. One specific trap: a property that asks for the model's thinking or step-by-step reasoning may trigger a reasoning_extraction refusal. Ask for a short explanation field instead.
Token limit. If stop_reason is "max_tokens", the JSON may be cut off mid-object. Retry with a higher max_tokens.
Enum casing. Capitalization of enum and const strings isn't guaranteed. A value can come back differing only in case, typically the first letter of a word after a space, with no error and no special stop_reason. Compare case-insensitively and don't define enum values that differ only by capitalization.
So check stop_reason before you parse. This runs offline against a fake response so you can see the shape of the guard:
import json
def parse_or_raise(stop_reason, text):
if stop_reason == "refusal":
raise RuntimeError("Model refused; output may not match the schema")
if stop_reason == "max_tokens":
raise RuntimeError("Truncated; retry with a higher max_tokens")
return json.loads(text)
print(parse_or_raise("end_turn", '{"name": "John Smith", "demo_requested": true}'))
try:
parse_or_raise("max_tokens", '{"name": "John Smi')
except RuntimeError as err:
print("caught:", err)
In real code the stop_reason comes from response.stop_reason and the text from the block you extracted earlier.
Combining JSON outputs with strict tool use#
The two features are independent, and you can use both in one request. JSON outputs control what Claude says; strict: true on a tool validates how Claude calls your function. The docs' example sets output_config.format for the final answer and gives a search_flights tool "strict": True with its own input_schema.
Strict tool schemas follow the same rules, including additionalProperties: False. Their optional parameters count toward the same 24 limit as your JSON output schema. If you're building an agent loop and only want validated tool inputs, strict tool use is the piece you need. If you're calling Claude from the command line instead, see Claude Code JSON output.
Compatibility and gotchas#
As of October 19, 2026, Anthropic's docs list these models as supporting structured outputs: Fable 5 and 5.1, Mythos 5, 5.1 and Preview, Opus 4.5 through 5.5, Sonnet 4.5 through 5.5, and Haiku 4.5 and 5.5. It's available on the Claude API, Claude Platform on AWS, Amazon Bedrock, Google Cloud and Microsoft Foundry, with a Bedrock footnote: on Amazon Bedrock it's on the legacy integration, not Claude in Amazon Bedrock. The release notes also say it isn't available for Claude Haiku 5.5 on Amazon Bedrock.
Mistakes to avoid:
- Using the old parameter. Structured outputs left beta on January 29, 2026, and
output_formatmoved tooutput_config.format. No beta header is needed on the Claude API. - Forgetting
additionalProperties: falseon a nested object. It's required on every object. - Putting limits in the schema.
minimum,maxLengthand friends are rejected. Validate those yourself after parsing, or describe them in the field description. - Prefilling the assistant turn. Message prefilling is incompatible with JSON outputs.
- Enabling citations. Citations with
output_config.formatreturn a 400 error. - Putting PHI in the schema. Schemas are cached for up to 24 hours since last use, separately from message content. Keep PHI in prompts and responses only, never in property names, enum values,
constvalues orpatternregexes.
It works with streaming, token counting, and the Batch API, which the docs say carries a 50% discount. Model IDs and prices change often, so check the models overview before pinning one. If you're comparing providers, the OpenAI API in Python article covers the other side.
Quick reference checklist#
- Put the schema at
output_config.formatwith"type": "json_schema". - Set
additionalPropertiestofalseon every object. - Avoid recursion, external
$ref, numeric and string constraints, andminItemsabove 1. - Stay under 20 strict tools, 24 optional parameters and 16 union-type parameters.
- Read the
textblock, checkstop_reason, thenjson.loads(). - Handle
refusalandmax_tokensseparately from parse errors. - Compare enum values case-insensitively.
- Expect a 400 with details for unsupported schema features.
Quick answers
How do I get Claude to return JSON that matches my schema?
Pass your schema in output_config.format with "type": "json_schema", then parse the first text block of the response with json.loads(). Claude's output is constrained to the schema through constrained decoding.
What happens if my JSON schema isn't supported?
The API returns a 400 error with details about the unsupported feature. Common causes are recursive schemas, external $ref, numeric or string constraints, and objects without additionalProperties set to false.
Can structured outputs still return invalid JSON?
Yes, in specific cases. A refusal (stop_reason: "refusal") or a truncated response (stop_reason: "max_tokens") can produce output that doesn't match the schema, so check stop_reason before parsing.
Is JSON structured data?
JSON is structured in syntax, but a JSON Schema is what pins down the exact keys, types and required fields. That declared shape is what structured outputs enforce.
Did this solve it?
One tap. No account.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
On a computer? Scan the code with your phone wallet, or copy the address.
Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.
Discussion
Start the discussion
Next note