Use Output Schemas
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:
| Area | What the schema controls |
|---|---|
| Correctness | Which values are allowed, and how missing data is represented |
| Reliability | Whether the schema compiles, and how often the output needs recovery |
| Performance | The first-request compile time, and how well compiled grammars are reused |
| Cost | Extra 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.
| Design | Output when the value is missing | Effect on complexity |
|---|---|---|
str | None, required | "phone": null | Counts as a union-type parameter |
str, optional (has a default) | The key is absent | Counts as an optional parameter |
str, required | Always 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:
| Limit | Value | What counts |
|---|---|---|
| Strict tools per request | 20 | Tools with strict: true |
| Optional parameters | 24 | Every property not listed in required, across all strict schemas |
| Union-type parameters | 16 | Every 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:
- Mark only the critical tools as strict.
- Reduce optional parameters. Make fields required where possible, and have Claude provide reasonable defaults explicitly.
- Simplify nested structures.
- Split the work across several requests.
Caching and Latency
Compiled grammars are cached for 24 hours from last use. The documentation describes three consequences:
| Event | Effect |
|---|---|
| First use of a schema | Extra latency while the grammar compiles |
| Repeated use within 24 hours | Fast; the compiled grammar is reused |
| Changing the schema's structure | The cache is invalidated, so the next request compiles again |
| Changing only descriptions (or tool names) | The cache is not invalidated |
Changing output_config.format | Invalidates 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
count_budget()walks the schema recursively, counting every property that is not in its object'srequiredlist (optional parameters) and every property whose type is a list or usesanyOf(union-type parameters). Thevisit()function also recurses into nested objects, because the documented limits cover all parameters, including nested ones.count_budget()on the sample finds four optional properties:price_inr,tags, andinstructorat the top level, plusbioinsideinstructor.prerequisitesis the only union-type property.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.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.- Only string descriptions are removed. A property named
descriptionis kept, because its value is a schema object, not a string. - 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
Literalenums 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
- Use
count_budget()on theTriagemodel from Lesson 101, after converting it withtransform_schema(). How many union-type and optional parameters does it have? - Add three strict tools to a request, each with 8 optional parameters. Does
budget_report()warn, and why? - Change the description of
leveland check whetherstructure_key()changes. Then change its enum values and check again. - Explain why
is_union()treats"anyOf" in nodeas a union, even thoughanyOfis not written as a type list.
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.