Claude Structured Outputs in Python: Guaranteed JSON (2026)
A runnable Python guide to Claude structured output: the native output_config.format JSON path, Pydantic-driven schemas, strict tool use, a portable tool-use fallback, and the 400/truncation gotchas nobody covers.
On this page
Getting a Claude model to return JSON you can
json.loads() used to mean prompt-begging and defensive parsing. As of 2026, the Claude Developer Platform ships native structured outputs, so the format is guaranteed by the API rather than requested in a system prompt. This tutorial builds the reliable path in Python, then keeps the older tool-use fallback for the cases native support does not cover.
Quick answer (2026)
Claude structured output is a native API feature that constrains the model's decoding so the response is guaranteed to match a JSON Schema you supply. You get it two ways: output_config.format with type: "json_schema" for a top-level JSON response, or "strict": true on a tool definition for schema-valid tool calls. It is generally available on Claude 4.5 and newer models. Every object in your schema must set additionalProperties: false, and a handful of JSON Schema keywords (minLength, maximum, recursive $ref) are not supported, so those constraints still belong in your own validation code.
What structured outputs actually guarantee
The mechanism is constrained decoding. Before generation, your schema is compiled into a grammar, and at each step the sampler is masked to only tokens that keep the output valid against that grammar. The model physically cannot emit a response that breaks the schema shape. That is a stronger promise than "ask nicely and parse", and it removes the entire class of retry loops that existed to catch malformed JSON.
Two surfaces expose it:
- JSON output format (
output_config.format): the whole assistant message is one JSON document matching your schema. Use this when the model's job is extraction or classification and the answer is the data. - Strict tool use (
"strict": trueon a tool): the arguments Claude passes to a tool are guaranteed schema-valid. Use this inside agent loops where the structured value is a function call, not the final answer.
Both are generally available on Claude 4.5 and later on the Claude Developer Platform, per Anthropic's structured outputs documentation (2026). The design mirrors OpenAI's structured outputs (2026), so the schema discipline below ports across providers with minor field renames.
Assumptions
- Python 3.10 or newer and the official anthropic Python SDK (
pip install anthropic). ANTHROPIC_API_KEYexported in your environment.- A current model id. The code uses a single
MODELconstant set toclaude-sonnet-5so you can swap it without hunting through the file.
import os, json
from anthropic import Anthropic
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
MODEL = "claude-sonnet-5"
A guaranteed JSON response
Define the schema as a plain dict. Note the two non-negotiables: additionalProperties: false on every object and a required array listing every property you expect.
INVOICE_SCHEMA = {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"total": {"type": "number"},
"currency": {"type": "string", "enum": ["USD", "EUR", "GBP"]},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"amount": {"type": "number"},
},
"required": ["description", "amount"],
"additionalProperties": False,
},
},
},
"required": ["vendor", "total", "currency", "line_items"],
"additionalProperties": False,
}
def extract_invoice(text: str) -> dict:
resp = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[{"role": "user", "content": f"Extract the invoice:\n\n{text}"}],
output_config={"format": {"type": "json_schema", "schema": INVOICE_SCHEMA}},
)
if resp.stop_reason == "max_tokens":
raise RuntimeError("response truncated; raise max_tokens and retry")
return json.loads(resp.content[0].text)
The json.loads call cannot throw on a schema violation, because the API would not have returned invalid JSON. It can still throw on a truncated response, which is why the stop_reason guard sits before it. More on that below.
If your installed SDK build does not yet type the output_config keyword, pass it through untyped with extra_body={"output_config": {...}}; the wire format is identical.
Drive the schema from Pydantic
Hand-writing schemas gets tedious and drifts from your types. Generate the schema from a Pydantic model instead, then patch it to satisfy the constrained-decoding rules. Pydantic emits a draft schema that is close but not compliant (it omits additionalProperties: false and may include unsupported keywords), so a small normalizer earns its keep.
from pydantic import BaseModel
class LineItem(BaseModel):
description: str
amount: float
class Invoice(BaseModel):
vendor: str
total: float
currency: str
line_items: list[LineItem]
UNSUPPORTED = {"minLength", "maxLength", "minimum", "maximum",
"multipleOf", "pattern", "format"}
def make_strict(node):
if isinstance(node, dict):
node = {k: make_strict(v) for k, v in node.items() if k not in UNSUPPORTED}
if node.get("type") == "object":
node["additionalProperties"] = False
node["required"] = list(node.get("properties", {}).keys())
return node
if isinstance(node, list):
return [make_strict(v) for v in node]
return node
def extract(model_cls, text: str):
schema = make_strict(model_cls.model_json_schema())
resp = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[{"role": "user", "content": f"Extract:\n\n{text}"}],
output_config={"format": {"type": "json_schema", "schema": schema}},
)
if resp.stop_reason == "max_tokens":
raise RuntimeError("response truncated")
return model_cls.model_validate_json(resp.content[0].text)
model_validate_json gives you a typed Invoice object with editor autocomplete downstream. Read the Pydantic JSON Schema reference (2026) if your models use $ref for nested types; make_strict flattens the common cases but not recursive definitions, which are unsupported anyway.
Strict tool use inside an agent loop
When the structured value is an action rather than the answer, put the schema on a tool and mark it strict. This is the shape you want in an orchestrator that returns a typed plan: the plan arrives as validated tool input.
BOOK_TOOL = {
"name": "book_flight",
"description": "Book a flight for the user.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"destination": {"type": "string"},
"date": {"type": "string"},
},
"required": ["destination", "date"],
"additionalProperties": False,
},
}
resp = client.messages.create(
model=MODEL,
max_tokens=512,
tools=[BOOK_TOOL],
tool_choice={"type": "tool", "name": "book_flight"},
messages=[{"role": "user", "content": "Fly me to Lisbon on 2026-09-14."}],
)
tool_use = next(b for b in resp.content if b.type == "tool_use")
args = tool_use.input # already schema-valid, no parsing needed
Forcing tool_choice to the single tool guarantees the model calls it, and strict guarantees the arguments match. You never touch json.loads here; the SDK hands you a dict.
A fallback for models without native support
Self-hosted models, older Claude versions, and some gateway providers do not offer constrained decoding. Keep one portable path that works anywhere: force a tool call the classic way and defensively parse. It is less safe than native structured outputs, so treat it as a compatibility shim, not the default.
def extract_json_fallback(text: str, schema: dict) -> dict:
resp = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[{
"role": "user",
"content": f"Return ONLY JSON matching this schema:\n"
f"{json.dumps(schema)}\n\nInput:\n{text}",
}],
)
raw = resp.content[0].text.strip()
if raw.startswith("```"): # strip a ```json fence if present
raw = raw.split("```", 2)[1]
raw = raw[4:] if raw.lstrip().startswith("json") else raw
start, end = raw.find("{"), raw.rfind("}")
return json.loads(raw[start:end + 1])
This is the pattern to retire the moment your target model supports the native API.
Making it reliable in production
Native structured outputs remove the malformed-JSON failure mode, but three real ones remain.
Scroll to see more
| Failure | Cause | Fix |
|---|---|---|
400 invalid_request_error | An object without additionalProperties: false, or an unsupported keyword | Run make_strict on every schema before sending |
| Silent constraint drop | minLength, maximum, multipleOf and friends are ignored, not enforced | Validate value ranges in your own code after parsing |
| Truncated JSON | max_tokens hit mid-document | Check stop_reason == "max_tokens" before parsing; raise max_tokens and retry |
Two more that bite teams at scale:
- First-call latency. Compiling a schema into a grammar and token masks is not free; the Hacker News thread on the launch (2025) flags it directly. Reuse the same schema object across calls so the compiled grammar stays warm, and expect the very first request against a new schema to be slower.
- Rate limits still apply. Constrained decoding does not exempt you from 429 and 529 responses. Wrap these calls in the same retry-with-backoff wrapper you use for every other Claude request.
A verify step
Prove the whole path end to end before you ship it. This is the AgentNotebook habit: never trust a helper you have not watched succeed and fail.
if __name__ == "__main__":
inv = extract(Invoice, "ACME Corp billed 2 units at 40 USD each, total 80.")
assert inv.vendor and inv.total == 80 and inv.currency == "USD"
assert len(inv.line_items) >= 1
print("ok:", inv.model_dump())
Run it, then deliberately set max_tokens=8 and confirm your stop_reason guard raises instead of handing you half an object.
Limitations and open questions
- Constrained decoding guarantees the shape, not the correctness. A schema-valid invoice can still have the wrong total. Structured outputs pair with, they do not replace, an evaluator that scores the content.
- The supported JSON Schema subset is real: no
minLength/maxLength, no numeric bounds,minItemsonly 0 or 1, no recursive schemas, no external$ref. Rich validation stays in application code. - Native support requires Claude 4.5 or newer on the Developer Platform. Bedrock and Vertex expose it for their own model lists; older or self-hosted models need the Step 4 fallback.
- Grammar-compilation latency for very large or deeply nested schemas is not yet well documented. If a schema is enormous, benchmark the first call before you put it on a hot path.
Written by
Ren OkabePrincipal engineer writing AgentNotebook's production-reliability tutorials. Cares about the failure modes, not the happy path.
Frequently asked questions
Is Claude structured output the same as tool use?
They overlap but differ. Structured output via output_config.format returns the whole assistant message as one schema-valid JSON document, best for extraction or classification. Strict tool use puts the schema on a tool and guarantees the tool's arguments are valid, best inside agent loops where the structured value is an action. Both use the same constrained-decoding engine.
Which Claude models support structured outputs in 2026?
On the Claude Developer Platform, structured outputs are generally available on Claude 4.5 and newer models, including Sonnet 4.5, Haiku 4.5, and the Claude 5 family. Amazon Bedrock and Google Vertex AI expose it for their own supported model lists. Older or self-hosted models need the tool-use fallback shown in Step 4.
Do I still need Pydantic validation if the output is guaranteed?
The format is guaranteed, the values are not. Constrained decoding enforces the JSON shape, but it cannot enforce keywords it does not support (minLength, maximum, multipleOf) and it never checks whether the extracted numbers are correct. Keep Pydantic or your own checks for value-level rules and for parsing the response into a typed object.
Why am I getting a 400 error about additionalProperties?
Structured outputs require every object in the schema to set additionalProperties to false, and require the required array to list all properties. Pydantic's generated schema omits both by default. Run the response through a normalizer (the make_strict helper in Step 2) before sending, and drop unsupported keywords in the same pass.
What happens if the JSON response is truncated?
Constrained decoding guarantees valid tokens, not a complete document. If the response hits max_tokens mid-object, stop_reason is set to max_tokens and the text is incomplete, so json.loads will throw. Always check stop_reason before parsing, then retry with a higher max_tokens.
Can I use minLength or maximum in a structured-output schema?
No. As of 2026 the supported subset excludes minLength, maxLength, minimum, maximum, multipleOf, recursive schemas, and external $ref. minItems supports only 0 or 1. Enforce those constraints in your application code after parsing rather than in the schema.
Related tutorials
Orchestrator-Workers Pattern in Python: A Claude Tutorial (2026)
Build the orchestrator-workers agent pattern in Python with Claude: a planner that decides subtasks at runtime, parallel workers, and a synthesizer. Runnable code, July 2026.
Handle Claude API 429 and 529 Errors in a Python Agent Loop (2026)
A 429 is your rate limit; a 529 is Anthropic overloaded. Catch both, honor retry-after, back off with jitter, cap the retries, and never retry a 400. Full runnable Python wrapper for agent loops.
Evaluator-Optimizer: Build a Self-Correcting LLM Agent in Python (2026)
The evaluator-optimizer workflow puts a rubric-driven critic in the loop so a generator revises until it passes. A runnable Python build with real guardrails.


