Classify a review as a bug report, a request, or praise, and get the result back as JSON. As an indie developer running apps on my own, I find a job like this quietly turns into two or three before I notice. And the code that works is the code I tend to leave alone.
The other day I wondered whether my own app code still carried the structured-outputs-2025-11-13 beta header. Rereading the official page, I found that the parameter has moved from output_format to output_config.format, and the beta header is no longer needed.
What I'd like to say first is that this migration is not just renaming one parameter. Beyond the rename, there are SDK behavior, prompt caching, schema limits, and the situations where the output won't match your schema. Here is the order of checks I pulled out of the official page before touching anything (checked on October 20, 2026).
What actually changed
The "Migrating from the beta" section is short. Three points matter.
| Item | What the docs say |
|---|---|
| Parameter | output_format moved to output_config.format. output_format is deprecated and will be removed |
| Beta header | No longer required. To keep the old form you need structured-outputs-2025-11-13; without it the API returns a 400 |
| Python SDK | From v1.0, passing output_format={...} to client.beta.messages.create() or count_tokens() raises a TypeError. Use output_config |
So the old form doesn't just "stop working someday." It may stop first on the SDK side, the moment you upgrade. The API is fine, yet your own code dies with a TypeError — a failure order that hides its cause.
Before and after
What this solves: replacing a call that relies on the beta header with the current form.
# Before: relied on output_format and the beta header (deprecated)
response = client.beta.messages.create(
model="claude-sonnet-5-5",
max_tokens=1024,
betas=["structured-outputs-2025-11-13"],
messages=[{"role": "user", "content": review_text}],
output_format={"type": "json_schema", "schema": REVIEW_SCHEMA},
)# After: pass the schema in output_config.format. No beta
import json
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": review_text}],
output_config={
"format": {"type": "json_schema", "schema": REVIEW_SCHEMA}
},
)
# The result is a JSON string inside the text block
text = next(b.text for b in response.content if b.type == "text")
result = json.loads(text)Why it's written this way: the switch from client.beta.messages.create to client.messages.create is the easy one to miss. If you delete only the header and keep the beta client, you can trip over behavior that changed on the SDK side.
There's one more thing that confused me at first. The SDK helper client.messages.parse(), which takes a Pydantic model, still names its argument output_format. The docs recommend this route: the SDK transforms the schema, sends it as output_config.format, validates the response, and gives you parsed_output.
from pydantic import BaseModel
class Review(BaseModel):
category: str
confidence: float
summary: str
parsed = client.messages.parse(
model="claude-sonnet-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": review_text}],
output_format=Review, # the helper's argument is still output_format
)
print(parsed.parsed_output)A blanket "find and delete output_format" would break this helper too. I'd search for raw messages.create-style calls and parse() separately.
What changes after you move
Fixing the names still leaves behavior to verify.
| Check | What the docs say | What to look at |
|---|---|---|
| First-request latency | The first request with a new schema is slower while the grammar compiles. Compiled grammars are cached for 24 hours from last use | Is only the first call after a deploy slow? |
| Grammar cache invalidation | Changing the schema structure invalidates it. Changing only name or description does not | Are you swapping schemas often? |
| Prompt cache | Changing output_config.format invalidates the prompt cache for that conversation thread | Did your cache hit rate drop after the move? |
| Input tokens | An extra system prompt describing the format is added automatically, so input tokens rise slightly | Input tokens on the bill, before and after |
The prompt cache is the one I'd watch most. If you build schemas dynamically per user, you may be breaking the cache on every request. A workable line: keep the schema fixed, and push the parts that vary into the message instead of the description.
What the schema can't say
While you're migrating, reread the constraints. The docs list these as unsupported:
- Recursive schemas, complex types inside enums, and external
$ref - Numeric constraints such as
minimum,maximum,multipleOf - String constraints such as
minLength,maxLength - Array constraints beyond a
minItemsof 0 or 1 additionalPropertiesset to anything other thanfalse
Using them returns a 400. If you declare ranges in Pydantic with something like Field(ge=0, le=1), the SDK helper strips the constraint, adds it to the field description, and still validates the response against your original schema. For a raw schema, transform_schema() from the anthropic package does the same transformation.
There are also complexity limits.
| Limit | Value |
|---|---|
Tools with strict: true per request | 20 |
| Optional parameters in total | 24 |
Parameters using union types (anyOf or type arrays) | 16 |
Going over returns a 400 with "Schema is too complex for compilation." For schemas heavy on optional fields, the docs suggest making them required and having Claude supply an explicit value when there's nothing to say.
Four times the output won't match your schema
Structured outputs follow the schema in most cases, and the docs are explicit about the exceptions. I'd put these four in the post-migration tests:
- Refusals (
stop_reason: "refusal"): you get a 200 and are billed, but the output may not match, because the refusal message takes precedence. - Truncation (
stop_reason: "max_tokens"): the JSON may be cut off. Retry with a highermax_tokens. - Enum casing: you may get a value that differs from your enum only in capitalization, typically the first letter of a word after a space. No error is raised. Compare case-insensitively.
- Incompatible features: combining with citations returns a 400, and prefilling is not compatible either.
# What this solves: reject non-conforming output before parsing it
def read_structured(response):
if response.stop_reason in ("refusal", "max_tokens"):
raise RuntimeError(f"may not match the schema: {response.stop_reason}")
text = next(b.text for b in response.content if b.type == "text")
return json.loads(text)Why it's written this way: checking stop_reason first gets you to the cause much sooner than a failed json.loads. And if you compare a category like category == "bug", add a .lower() before the comparison.
A minimal migration order
This is the order I settled on:
- Search the codebase for
output_formatandstructured-outputs-2025-11-13, counting raw calls andparse()separately. - Rewrite only the raw calls to
messages.createwithoutput_config.format, and drop the beta header. - Run the schema through
transform_schema()or the SDK helper to find the constraints that return a 400. - Add the
stop_reasoncheck and a case-insensitive enum comparison. - Compare first-request latency, cache hit rate, and input tokens once, before and after.
Learn how it breaks before you rename it. That's the one ordering I try not to give up when I migrate.
Start by searching your repository for structured-outputs-2025-11-13. If nothing turns up, this migration is already done. If something does, that line is the first place that will stop.