OpenAI Structured Output with o-series Models
Structured output works cleanly on o-series models — with three quirks. The strict-mode JSON schema has restrictions, reasoning tokens still consume the budget before validation runs, and refusals arrive on a separate channel your parser might not check. Here's the full pattern for reliable typed output from reasoning models.
By Ahmed R. · Last updated Aug 14, 2026 · OpenAI · Page #143
response_format={"type":"json_schema", "json_schema":{...}} (Chat Completions) or text={"format":{"type":"json_schema", "name":..., "schema":..., "strict":true}} (Responses). Strict mode enforces every keyword; the schema must set "additionalProperties":false on every object and mark every field required. Reasoning tokens still consume max_completion_tokens; budget generously (40K+ for medium effort). Model refusals come back as output_text in a refusal field, not as an exception — always check.Real error messages you'll see
openai.BadRequestError: Error code: 400 - {'error': {'message': "Invalid schema for response_format: schema must have 'additionalProperties': false on all objects when strict is true.", 'type': 'invalid_request_error'}}
# Pydantic doesn't emit additionalProperties by default. Add model_config = ConfigDict(extra="forbid") or use OpenAI's pydantic helper.
# resp.choices[0].message.content == ""
# resp.choices[0].finish_reason == "length"
# usage.completion_tokens_details.reasoning_tokens == 20_000
# Same #140 bug in a structured-output context: model reasoned to the budget cap, produced no JSON.
# resp.choices[0].message.refusal == "I cannot help with that request."
# resp.choices[0].message.content == None
# Model declined the request via the refusal channel. content is None; parsing crashes if you don't check.
Structured output shape by API
| API | Parameter path | strict location |
|---|---|---|
| Chat Completions | response_format={"type":"json_schema", "json_schema":{...}} | inside json_schema |
| Responses | text={"format":{"type":"json_schema", "name":..., "schema":..., "strict":true}} | top-level in format |
| SDK helper (Chat) | client.beta.chat.completions.parse(response_format=MyModel) | auto |
| SDK helper (Responses) | client.responses.parse(text_format=MyModel) | auto |
Root causes (ranked by frequency)
Based on OpenAI developer reports; percentages sum to 100%.
- 24%Pydantic schema missing
additionalProperties: false. Strict mode requires it on every nested object. Addmodel_config = ConfigDict(extra="forbid")to every model. - 18%Reasoning consumed the budget before JSON was written. Same as #140 in a structured context — model reasoned, then had no tokens left for output.
- 14%Refusal not checked. Model declined the request;
message.refusalcontains the reason,message.contentis None. Naive parser crashes. - 12%Wrong shape for the API surface.
response_formaton Responses (should betext.format), or nested Responses shape on Chat Completions. - 10%Optional fields marked required in strict mode. Strict mode requires every property listed in
required. Optional Pydantic fields still count; useUnion[X, None]instead. - 9%Schema uses unsupported keywords. Strict mode has a limited keyword allow-list —
oneOf,allOf, some string formats. Simplify or drop the SDK helper for hand-written schemas. - 8%Recursive schemas rejected. Self-referential Pydantic models often fail strict mode. Flatten or use unions instead.
- 5%Response parsed as JSON without validation. Non-strict schema returns "mostly-valid" JSON; downstream code crashes on edge cases. Always validate with Pydantic after parsing.
How to fix it
Use the SDK parse() helper — auto-schema, auto-validate, auto-refusal-check
The cleanest pattern for typed output from o-series.
The SDK's parse() helpers (client.beta.chat.completions.parse and client.responses.parse) auto-derive the JSON schema from a Pydantic model, submit it in strict mode, and return a validated Python instance. They also expose the refusal channel cleanly. This is the recommended pattern for structured output on o-series in 2026.
from openai import OpenAI
from pydantic import BaseModel, ConfigDict, Field
client = OpenAI()
# ✅ Pydantic model — extra="forbid" makes it strict-mode compatible
class BugReport(BaseModel):
model_config = ConfigDict(extra="forbid") # required for strict mode
title: str = Field(description="Short bug title")
severity: str = Field(description="One of: low, medium, high, critical")
steps_to_reproduce: list[str] = Field(min_length=1)
expected_behavior: str
actual_behavior: str
suspected_cause: str | None = Field(default=None, description="Optional cause hypothesis")
# ✅ Chat Completions with parse helper
resp = client.beta.chat.completions.parse(
model="o3",
messages=[
{"role": "developer", "content": "Extract a structured bug report from the user's description."},
{"role": "user", "content": "The login page 500s when the password has emoji. I tried logging in with 🔒hello and got a server error. Should just log me in."},
],
response_format=BugReport,
max_completion_tokens=25_000,
reasoning_effort="medium",
)
# ✅ CHECK REFUSAL FIRST
choice = resp.choices[0]
if choice.message.refusal:
print(f"Model refused: {choice.message.refusal}")
else:
report: BugReport = choice.message.parsed
print(report.title, report.severity)
# ✅ Responses API with parse helper
resp_r = client.responses.parse(
model="o3",
input=bug_description,
instructions="Extract a structured bug report.",
text_format=BugReport,
max_output_tokens=25_000,
reasoning={"effort": "medium"},
)
# Refusal check on Responses is per-item
report: BugReport | None = resp_r.output_parsed
if report is None:
# Look for refusal in output items
for item in resp_r.output:
if item.type == "message":
for part in item.content:
if part.type == "refusal":
print(f"Refused: {part.refusal}")
else:
print(report.title)
# ✅ Complete safe wrapper
class ParseFailure(Exception):
pass
class Refusal(Exception):
pass
def parse_or_raise(model: str, prompt: str, schema: type[BaseModel], budget: int = 25_000):
resp = client.beta.chat.completions.parse(
model=model,
messages=[{"role": "user", "content": prompt}],
response_format=schema,
max_completion_tokens=budget,
reasoning_effort="medium",
)
choice = resp.choices[0]
if choice.message.refusal:
raise Refusal(choice.message.refusal)
if choice.finish_reason == "length":
raise ParseFailure(f"Truncated at {budget} tokens; reasoning={resp.usage.completion_tokens_details.reasoning_tokens}")
if choice.message.parsed is None:
raise ParseFailure("Model returned unparseable output")
return choice.message.parsed
ConfigDict(extra="forbid") is the strict-mode key. Without it, generated schemas omit additionalProperties: false and OpenAI rejects them. Add it to every model class and every nested class used inside your schemas.Hand-written schemas — every object needs additionalProperties=false and every property required
Fixes strict-mode rejection when you write JSON schemas directly.
When you're writing schemas by hand (not via Pydantic), strict mode has two hard rules: every object must have additionalProperties: false, and every listed property must appear in the required array. "Optional" fields don't exist in strict mode — use type: ["string", "null"] and list them as required.
from openai import OpenAI
client = OpenAI()
# ❌ BUG — permissive schema fails in strict mode
bad_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"email": {"type": "string"}, # "optional" — not in required
},
"required": ["name"],
# Missing additionalProperties: false
}
# ✅ FIXED — strict-mode compliant
person_schema = {
"type": "object",
"additionalProperties": False, # required
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"email": {"type": ["string", "null"]}, # optional expressed via null union
},
"required": ["name", "age", "email"], # ALL properties listed
}
resp = client.chat.completions.create(
model="o3",
messages=[
{"role": "user", "content": "Extract: John, 42, no email on file"}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "Person",
"schema": person_schema,
"strict": True,
},
},
max_completion_tokens=15_000,
)
# ✅ Nested objects also need additionalProperties=false
company_schema = {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"hq": {
"type": "object",
"additionalProperties": False, # ← every nested object too
"properties": {
"city": {"type": "string"},
"country": {"type": "string"},
},
"required": ["city", "country"],
},
"employees": {
"type": "array",
"items": person_schema, # array of Person (already strict-compliant)
},
},
"required": ["name", "hq", "employees"],
}
# ✅ Responses API equivalent — schema at text.format.schema
resp_r = client.responses.create(
model="o3",
input="Extract: John, 42, no email",
text={
"format": {
"type": "json_schema",
"name": "Person", # ← top-level in Responses format
"schema": person_schema,
"strict": True,
},
},
max_output_tokens=15_000,
reasoning={"effort": "medium"},
)
# Parse the JSON manually since we're not using the parse helper
import json
data = json.loads(resp_r.output_text)
# ✅ Unsupported keywords to avoid in strict mode
# - Complex format validators (email, uri) — often silently ignored
# - Recursive $ref — often rejected; flatten instead
# - allOf composition — limited support
# - Pattern constraints — accepted but not enforced strictly
# When in doubt, run a validation call before deploying the schema
def validate_schema(schema: dict) -> list[str]:
"""Quick sanity check for strict-mode compliance."""
errors = []
def check_object(s, path):
if s.get("type") == "object":
if not s.get("additionalProperties") is False:
errors.append(f"{path}: missing additionalProperties: false")
required = set(s.get("required", []))
props = set(s.get("properties", {}).keys())
missing_req = props - required
if missing_req:
errors.append(f"{path}: properties not in required: {missing_req}")
for name, sub in s.get("properties", {}).items():
check_object(sub, f"{path}.{name}")
elif s.get("type") == "array":
if "items" in s:
check_object(s["items"], f"{path}[]")
check_object(schema, "$")
return errors
issues = validate_schema(person_schema)
if issues:
for e in issues:
print(f" · {e}")
validate_schema helper above catches the two most common strict-mode mistakes before you make the API call. Run it in your test suite against every schema you ship. Server-side rejection is a slow feedback loop; local validation is instant.Budget for reasoning + JSON — size max tokens generously
Prevents empty structured output.
Structured output doesn't change the reasoning budget dynamic — reasoning tokens still consume max_completion_tokens before JSON generation begins. If the model runs out, you get empty content and finish_reason="length". Budget: reasoning + expected JSON size + 20% headroom. For medium effort with a modest JSON output (~500 tokens), that's 25,000+ minimum.
from openai import OpenAI
from pydantic import BaseModel, ConfigDict
client = OpenAI()
class MarketReport(BaseModel):
model_config = ConfigDict(extra="forbid")
company: str
quarter: str
revenue_millions: float
yoy_growth_pct: float
key_products: list[str]
risks: list[str]
outlook_summary: str
# ✅ Adaptive retry — bump budget if reasoning exhausted it
def extract_report(text: str, effort: str = "medium"):
initial_budget = {"low": 8_000, "medium": 25_000, "high": 60_000}[effort]
budget = initial_budget
for attempt in range(3):
resp = client.beta.chat.completions.parse(
model="o3",
messages=[
{"role": "developer", "content": "Extract a structured market report from the document."},
{"role": "user", "content": text},
],
response_format=MarketReport,
max_completion_tokens=budget,
reasoning_effort=effort,
)
choice = resp.choices[0]
if choice.message.refusal:
raise ValueError(f"Refused: {choice.message.refusal}")
if choice.finish_reason == "stop" and choice.message.parsed:
return choice.message.parsed
# Truncated — check where the budget went
reasoning = resp.usage.completion_tokens_details.reasoning_tokens
visible = resp.usage.completion_tokens - reasoning
if reasoning >= budget * 0.9:
# Reasoning burned budget — retry with more
budget = min(budget * 2, 100_000)
continue
if visible > 0 and visible < 100:
# Reasoning succeeded but JSON was tiny/incomplete — model may be confused
raise ValueError(f"Model produced only {visible} visible tokens; JSON incomplete")
raise RuntimeError(f"Truncated at {budget}, reasoning={reasoning}, visible={visible}")
raise RuntimeError("Repeated exhaustion — consider lower effort or simpler schema")
# ✅ Reduce budget pressure — simplify the schema
# Complex nested schemas force more reasoning to plan the structure.
# When budget is tight:
# - Flatten nested objects
# - Split into two calls (extract → refine)
# - Use effort="low" if the task is straightforward
class SimplifiedReport(BaseModel):
model_config = ConfigDict(extra="forbid")
company: str
revenue: float
growth_pct: float
# ✅ Two-stage extraction — first pass simple, second pass detailed
def two_stage_extract(text: str) -> MarketReport:
# Stage 1 — quick core facts (low effort)
core = client.beta.chat.completions.parse(
model="o3",
messages=[{"role": "user", "content": f"Extract core facts:\n{text}"}],
response_format=SimplifiedReport,
max_completion_tokens=5_000,
reasoning_effort="low",
).choices[0].message.parsed
# Stage 2 — enrich with analysis (medium effort)
enriched = client.beta.chat.completions.parse(
model="o3",
messages=[{"role": "user", "content": (
f"Given these core facts: {core.model_dump()}\n\n"
f"Original text: {text}\n\n"
f"Produce the full MarketReport."
)}],
response_format=MarketReport,
max_completion_tokens=20_000,
reasoning_effort="medium",
)
return enriched.choices[0].message.parsed
Prevention checklist
- Add
model_config = ConfigDict(extra="forbid")to every Pydantic model used with strict-mode structured output. - For hand-written schemas: every object needs
additionalProperties: false; every listed property must appear inrequired. - Express "optional" via type unions with null:
{"type": ["string", "null"]}, still listed as required. - Always check the refusal channel before parsing —
choice.message.refusal(Chat) or the refusal part in output items (Responses). - Budget generously for reasoning + JSON: medium effort ≥25K, high effort ≥60K, when structured output is required.
- Use SDK
parse()helpers when possible — they auto-schema, auto-validate, and simplify the refusal check. - For complex schemas, split into two-stage extraction — cheaper AND more accurate than one deep call.
Frequently asked questions
OpenAI's strict mode requires additionalProperties: false on every object in the schema, which tells the model "no extra fields allowed." Pydantic's default is to allow extras (they're silently dropped in extra="ignore" mode), so its schema output omits additionalProperties. Setting extra="forbid" makes Pydantic emit the correct schema.
Strict mode is enforced server-side — the model literally cannot produce non-compliant JSON. Fields will always be present with the correct types. Where strict mode falls short: it doesn't validate semantic constraints (min/max, string patterns, enum membership are only weakly enforced). Validate with Pydantic after parsing to catch semantic issues.
Support is limited. Straightforward recursion (a tree with children of the same type) often works via $ref; deeply recursive schemas or mutually-recursive types often fail strict mode. When it doesn't work, flatten to a list with parent-id references, or split into non-recursive stages.
Slightly. The schema is sent with every request and counts toward input tokens (usually small — hundreds of tokens for typical schemas). Output-side, structured JSON is generally shorter and more predictable than free-form text, so per-response cost is often net-neutral or lower. On reasoning models, the schema is one of the things the model reasons about, so complex schemas can inflate reasoning-token usage.
Use "enum": ["value1", "value2", ...] with "type": "string". Strict mode enforces the enum. Pydantic Literal types translate cleanly: severity: Literal["low", "medium", "high"] becomes an enum-constrained string in the generated schema.