Use Output Schemas

Su Suriya Ravichandran Updated 10 Nov 2026
9 min read ·Lesson 111 of 200

Schemas Are Designs

A schema (Lessons 101–104) is more than a list of fields. It determines what Claude must produce, how long the response is, how expensive the request is, and whether the API can compile it at all. Schema design therefore has consequences in four areas:

AreaWhat the schema controls
CorrectnessWhich values are allowed, and how missing data is represented
ReliabilityWhether the schema compiles, and how often the output needs recovery
PerformanceThe first-request compile time, and how well compiled grammars are reused
CostExtra input tokens for the format instructions, and the size of the output

This lesson covers how to design schemas for all four, including the complexity limits that the API enforces.


Designing the Fields

Prefer Required Fields

Required fields keep the output's shape stable. A required nullable field always appears, as a value or as null. An optional field may be absent, which code must handle. Use optional fields only when absence means something different from null, or when the field is rarely needed.

DesignOutput when the value is missingEffect on complexity
str | None, required"phone": nullCounts as a union-type parameter
str, optional (has a default)The key is absentCounts as an optional parameter
str, requiredAlways a string (Claude must provide one)Cheapest; use when a value always exists

Use Enums for Categories

Fixed categories belong in Literal types, which become enum lists. They make output easier to aggregate and prevent drift:

category: Literal["billing", "technical", "account", "other"]

Do not use free text for values you will count or group. Remember that enum capitalization is not guaranteed (Lesson 101), so normalize before comparing.

Keep Structures Flat

Deep nesting makes schemas harder for Claude to follow and more expensive to compile. Prefer a flat object with a few nested objects over several layers:

# Harder: three levels of nesting
order.shipping.address.city

# Easier: flatter structure
order.shipping_city

Flatten when the nested structure has no meaning of its own.

Order Matters in Output

Properties come out in schema order, with required properties first (Lesson 102). If your application writes fields in a fixed order, for example to a CSV file, either make every field required or reorder the values in code.


Complexity Limits

The documentation describes limits that apply to all requests with output_config.format or strict: true. They apply to the combined total across every strict schema in one request:

LimitValueWhat counts
Strict tools per request20Tools with strict: true
Optional parameters24Every property not listed in required, across all strict schemas
Union-type parameters16Every parameter using anyOf or a type array such as ["string", "null"]

Beyond these explicit limits, the compiled grammar also has an internal size limit. A schema that passes every explicit limit can still be rejected with a 400 error, "Schema is too complex for compilation", and compilation itself has a timeout of 180 seconds.

The documentation's advice, in order:

  1. Mark only the critical tools as strict.
  2. Reduce optional parameters. Make fields required where possible, and have Claude provide reasonable defaults explicitly.
  3. Simplify nested structures.
  4. Split the work across several requests.

Caching and Latency

Compiled grammars are cached for 24 hours from last use. The documentation describes three consequences:

EventEffect
First use of a schemaExtra latency while the grammar compiles
Repeated use within 24 hoursFast; the compiled grammar is reused
Changing the schema's structureThe cache is invalidated, so the next request compiles again
Changing only descriptions (or tool names)The cache is not invalidated
Changing output_config.formatInvalidates the prompt cache for that conversation thread (Lesson 121)

The practical lessons: keep schemas stable, change descriptions freely when you improve guidance, and avoid rebuilding the schema with different structures on every request. A schema generated from a Pydantic model is stable as long as the model's structure is stable.


Practical Example: A Schema Budget Checker

This module measures a schema's optional parameters and union-type parameters against the documented limits, and includes a fingerprint that detects changes that would invalidate the grammar cache. It runs entirely offline, so you can check schemas during development and in tests.

"""schema_budget.py: measure a schema against the documented structured-output limits."""

import hashlib
import json

OPTIONAL_LIMIT = 24
UNION_LIMIT = 16
STRICT_TOOL_LIMIT = 20


def is_union(node: dict) -> bool:
    return isinstance(node.get("type"), list) or "anyOf" in node


def count_budget(schema: dict) -> dict:
    counts = {"optional": 0, "union": 0}

    def visit(node):
        if isinstance(node, dict):
            if node.get("type") == "object" and isinstance(node.get("properties"), dict):
                required = set(node.get("required", []))
                for name, child in node["properties"].items():
                    if name not in required:
                        counts["optional"] += 1
                    if isinstance(child, dict) and is_union(child):
                        counts["union"] += 1
            for value in node.values():
                visit(value)
        elif isinstance(node, list):
            for value in node:
                visit(value)

    visit(schema)
    return counts


def budget_report(schemas: list[dict], strict_tools: int = 0) -> list[str]:
    """Check the combined budget of every strict schema that will be sent in one request."""
    total = {"optional": 0, "union": 0}
    for schema in schemas:
        counts = count_budget(schema)
        total["optional"] += counts["optional"]
        total["union"] += counts["union"]

    warnings = []
    if total["optional"] > OPTIONAL_LIMIT:
        warnings.append(f"{total['optional']} optional parameters (limit {OPTIONAL_LIMIT})")
    if total["union"] > UNION_LIMIT:
        warnings.append(f"{total['union']} union-type parameters (limit {UNION_LIMIT})")
    if strict_tools > STRICT_TOOL_LIMIT:
        warnings.append(f"{strict_tools} strict tools (limit {STRICT_TOOL_LIMIT})")
    return warnings


def structure_key(schema: dict) -> str:
    """Fingerprint a schema without its descriptions, approximating the grammar-cache key."""
    def strip(node):
        if isinstance(node, dict):
            return {k: strip(v) for k, v in node.items()
                    if not (k == "description" and isinstance(v, str))}
        if isinstance(node, list):
            return [strip(v) for v in node]
        return node

    canonical = json.dumps(strip(schema), sort_keys=True)
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]


if __name__ == "__main__":
    course_schema = {
        "type": "object",
        "properties": {
            "title": {"type": "string", "description": "Course title"},
            "level": {"type": "string", "enum": ["beginner", "intermediate", "advanced"]},
            "prerequisites": {"type": ["string", "null"], "description": "Required prior knowledge"},
            "price_inr": {"type": "integer", "description": "Price in rupees"},
            "tags": {"type": "array", "items": {"type": "string"}},
            "instructor": {
                "type": "object",
                "properties": {"name": {"type": "string"}, "bio": {"type": "string"}},
                "required": ["name"],
                "additionalProperties": False,
            },
        },
        "required": ["title", "level", "prerequisites"],
        "additionalProperties": False,
    }
    print(count_budget(course_schema))
    print(budget_report([course_schema]) or "within limits")

    renamed = json.loads(json.dumps(course_schema))
    renamed["properties"]["title"]["description"] = "Course name"
    print(structure_key(course_schema) == structure_key(renamed))

    changed = json.loads(json.dumps(course_schema))
    del changed["properties"]["price_inr"]
    print(structure_key(course_schema) == structure_key(changed))

    too_many = {"type": "object",
                "properties": {f"field_{i}": {"type": "string"} for i in range(25)},
                "required": [], "additionalProperties": False}
    print(budget_report([too_many]))

Output:

{'optional': 4, 'union': 1}
within limits
True
False
['25 optional parameters (limit 24)']

How It Works

  1. count_budget() walks the schema recursively, counting every property that is not in its object's required list (optional parameters) and every property whose type is a list or uses anyOf (union-type parameters). The visit() function also recurses into nested objects, because the documented limits cover all parameters, including nested ones.
  2. count_budget() on the sample finds four optional properties: price_inr, tags, and instructor at the top level, plus bio inside instructor. prerequisites is the only union-type property.
  3. budget_report() adds up the counts across every strict schema in a request, because the documented limits apply to the combined total. It also checks the strict-tool count.
  4. structure_key() removes descriptions and hashes the rest. Changing a description leaves the key unchanged, matching the documented behavior that description changes do not invalidate the grammar cache. Removing a field changes the key.
  5. Only string descriptions are removed. A property named description is kept, because its value is a schema object, not a string.
  6. The 25-field example shows the warning the API would otherwise produce only at request time, as a compile error.

Note: the checker counts parameters the way the documentation describes, but it does not reproduce the API's internal size limit. A schema that passes this check can still fail to compile. Treat the checker as an early warning, and use the API's error message as the final answer.


Common Mistakes

Designing Schemas Without Counting Parameters

What happens: a large schema fails with "Schema is too complex for compilation" only after the team has built a feature around it.

Fix: check the budget during development and in tests.

Making Everything Optional

What happens: the optional-parameter limit is reached quickly, and output keys vary between responses.

Fix: make fields required, with nullable types where values can be missing.

Rebuilding Schemas with Changing Structure

What happens: each request compiles a new grammar, adding latency.

Fix: keep schema structure stable, and generate it from a single definition.

Deep Nesting for Its Own Sake

What happens: harder schemas for Claude, higher compile cost, and more mistakes.

Fix: flatten structures that do not carry their own meaning.

Ignoring Property Order

What happens: a CSV export has columns in the wrong order.

Fix: make fields required, or map values by name in code.


Best Practices

  • Prefer required fields, and use nullable types only where values can be genuinely missing.
  • Use Literal enums for categories.
  • Keep schemas flat and stable.
  • Check the parameter budget across all strict schemas in a request.
  • Change descriptions freely, and avoid changing structure without need.
  • Split large extraction tasks across several requests.
  • Keep the API's compile error as the final authority.

Exercise

  1. Use count_budget() on the Triage model from Lesson 101, after converting it with transform_schema(). How many union-type and optional parameters does it have?
  2. Add three strict tools to a request, each with 8 optional parameters. Does budget_report() warn, and why?
  3. Change the description of level and check whether structure_key() changes. Then change its enum values and check again.
  4. Explain why is_union() treats "anyOf" in node as a union, even though anyOf is not written as a type list.
Show answers for steps 1, 2, and 4

Step 1: zero of each. Triage has five fields, all required: category, priority, summary, and needs_human have no defaults, and none uses a type list or anyOf. Its Literal fields become enum lists, which are not counted. A model with a nullable or defaulted field would show a count, so this example is a good check of the counter's behavior.

Step 2: yes. Three tools with 8 optional parameters each total 24 optional parameters. That is exactly at the limit, so budget_report() reports nothing, because the check is "greater than 24". Adding one more optional parameter anywhere in the request produces a warning. This is the combined-total effect the documentation describes: no single tool looks complex, but the request as a whole reaches the limit.

Step 4: anyOf lets one property accept one of several schemas, for example a string or a null. It creates the same kind of branching in the compiled grammar as a type list, which is why the documentation counts both as union-type parameters. Treating anyOf as a union keeps the checker aligned with the documented limit.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Use Output Schemas and get answers drawn from it.

Signed-in readers only.