Getting clean JSON out of an LLM used to mean begging in the prompt and hoping. Today Claude, OpenAI and Gemini all offer structured outputs: you pass a JSON schema, and the model’s reply is forced to match it.
This tutorial uses one Pydantic model and shows the exact Python code for all three APIs. You also get each provider’s limits, a side-by-side comparison table, and fixes for the errors people hit most. Checked against the official docs on November 9, 2026.
- Claude:
client.messages.parse(..., output_format=Model), readparsed_output. The raw parameter isoutput_config.format, no beta header needed. - OpenAI:
client.responses.parse(..., text_format=Model), readoutput_parsed. - Gemini: pass
Model.model_json_schema()as the schema, then validate with Pydantic yourself. - Biggest gotchas: OpenAI needs every field required, Claude limits optional and union fields, Gemini ignores length rules.
- Always validate: refusals and token cut-offs can still break the schema on every provider.
What Structured Outputs Are (and Why JSON Mode Is Not Enough)
Structured outputs use constrained decoding. The provider turns your schema into a grammar, and the model can only produce tokens that fit it. The result parses every time, with the fields and types you asked for.
That is different from older “JSON mode”, which only promises valid JSON.
- JSON mode: valid JSON, but fields can be missing, renamed or the wrong type.
- Structured outputs: valid JSON and it follows your schema.
- Tool or function calling: use this when the model should call your code. Use structured outputs when you want the reply itself as data.
OpenAI’s docs recommend structured outputs over JSON mode whenever possible. Anthropic also has a sister feature called strict tool use (strict: true) that applies the same guarantee to tool inputs.
Claude vs OpenAI vs Gemini: Structured Outputs Compared
| Feature | Claude (Anthropic) | OpenAI | Gemini (Google) |
|---|---|---|---|
| Raw parameter | output_config.format with type: "json_schema" |
text.format (Responses) or response_format (Chat Completions) |
response_format with a schema (Interactions API) |
| Python Pydantic helper | messages.parse returns parsed_output |
responses.parse returns output_parsed |
No parse helper; use model_validate_json |
| Models | All current models | GPT-4o and later | Gemini 3 and 4 series |
| All fields required? | No (optional allowed, max 24) | Yes (use nullable for optional) | No |
additionalProperties: false |
Required (SDK adds it) | Required | Optional |
| Recursive schemas | Not supported | Supported | Supported |
| Min/max numbers | Not enforced (SDK moves to description) | Supported | Supported |
| String length rules | Not enforced | Supported | Ignored |
| Streaming | Yes | Yes | Yes (partial JSON chunks) |
| Published size limits | 20 strict tools, 24 optional, 16 union fields | 5,000 properties, 10 levels deep | None published |
Setup and the Shared Pydantic Model
We will extract invoice details from messy text. The same Pydantic model works for all three providers.
- Create a virtual environment with Python 3.10 or newer.
- Install the SDKs:
pip install anthropic openai google-genai pydantic - Set your keys as environment variables:
ANTHROPIC_API_KEY,OPENAI_API_KEYandGEMINI_API_KEY. - Save the model below in
schema.pyand import it in each example. - Run each script and compare the parsed objects.
from typing import Optional
from pydantic import BaseModel, Field
class LineItem(BaseModel):
description: str
quantity: int
unit_price: float
class Invoice(BaseModel):
vendor: str = Field(description="Company that sent the invoice")
invoice_number: str
issue_date: str = Field(description="Date in YYYY-MM-DD format")
currency: str = Field(description="Three-letter code like USD or EUR")
items: list[LineItem]
total: float
po_number: Optional[str] # nullable, still required
TEXT = """Invoice INV-2291 from Northwind Supplies, dated 3 Nov 2026.
2 x USB-C dock at $89.50 each, 1 x 27-inch monitor at $249.00.
Total due: $428.00 USD. No purchase order number."""
Tip: Note po_number: Optional[str] with no default. That makes it “required but can be null”, which is the pattern OpenAI needs and which works on Claude and Gemini too.
New to these SDKs? My Claude API, OpenAI API and Gemini API Python tutorials cover keys and basic calls.
Claude Structured Outputs in Python
Anthropic’s structured outputs work on all current Claude models and all cloud platforms, with no beta header. The Python SDK’s messages.parse helper takes your Pydantic class directly.
from anthropic import Anthropic
from schema import Invoice, TEXT
client = Anthropic()
response = client.messages.parse(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": f"Extract the invoice details:\n{TEXT}"}],
output_format=Invoice,
)
invoice = response.parsed_output
print(invoice.total, invoice.items[0].description)
What the Claude SDK does for you
- Cleans the schema: removes rules Claude cannot enforce, such as
minimumormaxLength. - Keeps the hint: adds each removed rule to the field description, so the model still sees it.
- Locks objects: adds
additionalProperties: falseeverywhere. - Validates: checks the reply against your original model and returns it in
parsed_output.
Without the helper, send the schema yourself in output_config={"format": {"type": "json_schema", "schema": {...}}} and parse the text block with json.loads. The SDK’s transform_schema() turns a Pydantic model into a compatible schema.
Warning: the old output_format request parameter is deprecated. It now returns a 400 error unless you send the structured-outputs-2025-11-13 beta header, so move raw calls to output_config.
Claude compatibility notes
- Works with: streaming, extended thinking, batch processing (50% off) and strict tool use in the same request.
- Does not work with: citations (400 error) or message prefilling.
- First call is slower: the grammar compiles once, then is cached for 24 hours from last use.
- Prompt cache: changing the format resets the prompt cache for that thread.
OpenAI Structured Outputs in Python
OpenAI supports structured outputs on GPT-4o and every later model. Its docs suggest gpt-6-astra for new projects. With the Responses API, use responses.parse and text_format.
from openai import OpenAI
from schema import Invoice, TEXT
client = OpenAI()
response = client.responses.parse(
model="gpt-6-astra",
input=[
{"role": "system", "content": "Extract the invoice details."},
{"role": "user", "content": TEXT},
],
text_format=Invoice,
)
invoice = response.output_parsed
print(invoice.total, invoice.po_number)
On the older Chat Completions API, the same idea uses client.chat.completions.parse(..., response_format=Invoice) and choices[0].message.parsed.
OpenAI schema rules to remember
- Root is an object: the top level cannot be an
anyOf. - Every field is required: show optional values as nullable, like
Optional[str]with no default. - No extra keys:
additionalProperties: falseon every object (the SDK handles this). - Not supported:
allOf,not,if/then/else,dependentRequiredanddependentSchemas. - Refusals: a safety refusal comes back as a refusal, not as your object. Check for it before using the data.
Gemini Structured Outputs in Python
Google’s current docs (updated October 21, 2026) show structured output through the Interactions API in the google-genai SDK. You pass a plain JSON Schema, which Pydantic generates for you.
from google import genai
from schema import Invoice, TEXT
client = genai.Client()
interaction = client.interactions.create(
model="gemini-3.8-flash",
input=f"Extract the invoice details:\n{TEXT}",
response_format={
"type": "text",
"mime_type": "application/json",
"schema": Invoice.model_json_schema(),
},
)
invoice = Invoice.model_validate_json(interaction.output_text)
print(invoice.vendor, invoice.total)
If you use the older generate_content method, the same schema goes in the config as response_mime_type="application/json" plus response_json_schema.
Gemini notes
- Supported keywords: types,
enum,format(date-time, date, time),minimum/maximum,items,prefixItems,minItems/maxItems, plusanyOfand recursive$ref. - Silently ignored:
minLength,maxLength,minProperties,maxProperties,uniqueItemsandcontains. Check these in your own code. - Tools: Gemini 3 series models can combine structured output with Google Search, URL Context, Code Execution, File Search and function calling (preview).
- Streaming: with
stream=Trueyou get partial JSON text chunks that join into the final object.
Schema Limits per Provider
Every provider has a ceiling on schema size and complexity. Hitting it gives a 400 error, not a bad answer.
| Limit | Claude | OpenAI | Gemini |
|---|---|---|---|
| Object properties | Not published | 5,000 total | Not published |
| Nesting depth | Not published | 10 levels | Not published |
| Optional fields | 24 across all strict schemas | None (all required) | Not published |
Union (anyOf) fields |
16 | Not published | Many alternatives can fail |
| Enum values | Not published | 1,000 across the schema | Many values can fail |
| Name and enum text budget | Not published | 120,000 characters | Very long names can fail |
| Strict tools per request | 20 | n/a | n/a |
| Compile timeout | 180 seconds | Not published | Not published |
Note: OpenAI’s numeric limits come from a developer comparison that cites OpenAI’s docs (October 2026). Anthropic and Google publish their own figures and error causes directly.
One Function for All Three Providers
If you switch providers or want a fallback, wrap the three calls behind one function. Pydantic gives you the same object no matter which model answered.
from anthropic import Anthropic
from openai import OpenAI
from google import genai
from pydantic import ValidationError
from schema import Invoice, TEXT
claude = Anthropic()
openai_client = OpenAI()
gemini = genai.Client()
def extract(provider: str, text: str, model_cls):
if provider == "claude":
r = claude.messages.parse(
model="claude-opus-5-5", max_tokens=1024,
messages=[{"role": "user", "content": text}],
output_format=model_cls,
)
return r.parsed_output
if provider == "openai":
r = openai_client.responses.parse(
model="gpt-6-astra", input=text, text_format=model_cls,
)
return r.output_parsed
if provider == "gemini":
r = gemini.interactions.create(
model="gemini-3.8-flash", input=text,
response_format={"type": "text", "mime_type": "application/json",
"schema": model_cls.model_json_schema()},
)
return model_cls.model_validate_json(r.output_text)
raise ValueError(provider)
for p in ["claude", "openai", "gemini"]:
try:
print(p, extract(p, TEXT, Invoice))
except ValidationError as e:
print(p, "did not match the schema:", e)
Want to compare what each call costs? See my AI API pricing comparison.
Schema Design Tips That Save Pain
- Describe every field:
Field(description=...)is the model’s main hint about what goes where. - Use enums for categories: they stop the model from inventing labels. On Claude, compare enum values case-insensitively, as its docs warn casing can shift.
- Keep it flat: fewer nested levels and optional fields compile faster and fail less.
- Avoid “reasoning” fields on Claude: properties that ask for step-by-step thinking can trigger a refusal. Ask for a short explanation instead.
- Put order-sensitive fields first: Claude outputs required fields before optional ones.
- Validate business rules yourself: a schema-valid total can still be wrong. Check sums and dates in code.
Troubleshooting: Common Problems and Fixes
- Claude 400 “Schema is too complex for compilation”: cut optional fields below 24, union fields below 16, and mark fewer tools as strict.
- Claude 400 on
output_format: you are sending the deprecated raw parameter. Switch tooutput_config.format. - Claude 400 with citations: citations cannot be combined with JSON outputs. Turn one off.
- OpenAI “all fields must be required”: remove Pydantic defaults and use
Optional[...]for fields that may be empty. - OpenAI
output_parsedis None: the model refused or stopped early. Check the refusal and the response status. - Gemini 400 INVALID_ARGUMENT: shorten names, cut enum values, reduce nested
maxItemsand narrow numeric ranges. - Gemini ignores my length limit:
minLengthandmaxLengthare stripped. Validate length after parsing. - Cut-off JSON on any provider: the reply hit the token limit. Raise
max_tokensand retry. - Rate limit errors: see my 429 and 529 error fix guide.
Frequently Asked Questions
Does Claude support structured outputs without a beta header?
Yes. Claude’s structured outputs use output_config.format and work on all current models with no beta header. Only the deprecated output_format parameter needs the old beta header.
What is the difference between JSON mode and structured outputs?
JSON mode only guarantees valid JSON. Structured outputs also guarantee the reply matches your schema’s fields and types.
Can I use the same Pydantic model for Claude, OpenAI and Gemini?
Yes, if you keep it simple. Make optional fields nullable with no default, avoid recursion for Claude, and validate length rules yourself for Gemini.
Which provider is best for strict JSON?
OpenAI enforces the widest set of schema rules, including number ranges and string patterns. Claude is very reliable with simpler schemas, and Gemini is flexible but ignores some rules.
Do structured outputs cost extra?
No separate fee. Claude adds a small system prompt that uses extra input tokens, and the first call is slower while the grammar compiles.
Can structured outputs still fail?
Yes. A refusal or hitting the token limit can return output that does not match the schema, so always check the stop reason and validate.
Do structured outputs work with streaming?
Yes, on all three. You receive partial JSON as it is generated and parse it once the stream ends.




