◉CLAUDE LABJP
●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.301 — Unattended retries now stop on their own when a usage limit is hit instead of waiting (10/15). Latest is 2.1.303 (10/18)●SONNET 4.5 — Retires on the Claude API on 11/30, 41 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.301 — Unattended retries now stop on their own when a usage limit is hit instead of waiting (10/15). Latest is 2.1.303 (10/18)●SONNET 4.5 — Retires on the Claude API on 11/30, 41 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-10-20Intermediate

Moving Structured Outputs to output_config.format — What to Check Before You Drop the Beta Header

A checklist for moving structured outputs from output_format and the beta header to output_config.format, drawn from the official docs: the Python SDK TypeError, prompt-cache invalidation, schema limits, and the cases where output won't match your schema.

Claude API130structured outputsoutput_configmigration10Python SDK

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.

ItemWhat the docs say
Parameteroutput_format moved to output_config.format. output_format is deprecated and will be removed
Beta headerNo longer required. To keep the old form you need structured-outputs-2025-11-13; without it the API returns a 400
Python SDKFrom 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.

CheckWhat the docs sayWhat to look at
First-request latencyThe first request with a new schema is slower while the grammar compiles. Compiled grammars are cached for 24 hours from last useIs only the first call after a deploy slow?
Grammar cache invalidationChanging the schema structure invalidates it. Changing only name or description does notAre you swapping schemas often?
Prompt cacheChanging output_config.format invalidates the prompt cache for that conversation threadDid your cache hit rate drop after the move?
Input tokensAn extra system prompt describing the format is added automatically, so input tokens rise slightlyInput 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 minItems of 0 or 1
  • additionalProperties set to anything other than false

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.

LimitValue
Tools with strict: true per request20
Optional parameters in total24
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:

  1. Refusals (stop_reason: "refusal"): you get a 200 and are billed, but the output may not match, because the refusal message takes precedence.
  2. Truncation (stop_reason: "max_tokens"): the JSON may be cut off. Retry with a higher max_tokens.
  3. 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.
  4. 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:

  1. Search the codebase for output_format and structured-outputs-2025-11-13, counting raw calls and parse() separately.
  2. Rewrite only the raw calls to messages.create with output_config.format, and drop the beta header.
  3. Run the schema through transform_schema() or the SDK helper to find the constraints that return a 400.
  4. Add the stop_reason check and a case-insensitive enum comparison.
  5. 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ API & SDK2026-08-30
Read the Catalog Your CLI Already Ships Before Bulk-Replacing Model IDs
A newer generation makes older model IDs look stale. Here is how to pull the catalog embedded in your installed binary, sort every reference into matched, date-mismatched, and malformed, and stop the replacement that would break working IDs.
⬡ API & SDK2026-10-18
43 Days Until Sonnet 4.5 Retires: I Counted Callers From the Usage CSV, Not From grep
claude-sonnet-4-5 retires on November 30. grep only shows what is written in code. Here is a single script that cross-checks model IDs in your code against the Console's Usage CSV, with the results from running it.
⬡ API & SDK2026-10-16
Sending 40 Rows From Make to the Claude API Stops at Row 14 — Avoiding the 429 With One Subtraction
Why a no-code automation that sends spreadsheet rows to the Claude API one by one hits a 429 partway through, and how to fix it. Seven spacing strategies tested on a mock bucket, a formula for the Sleep delay, and settings that stop failed rows from vanishing.