OpenAI Structured Output with o-series Models — Fix Guide (2026)
Reasoning Models · Structured Output Severity: Medium 400 / n/a

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.

TL;DROn o-series, use 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

Strict schema rejected — additionalProperties required
Strict schema rejected — additionalProperties required
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.
Empty output — reasoning exhausted budget before JSON generation
Empty output — reasoning exhausted budget before JSON generation
# 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.
Refusal instead of JSON
Refusal instead of 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

APIParameter pathstrict location
Chat Completionsresponse_format={"type":"json_schema", "json_schema":{...}}inside json_schema
Responsestext={"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. Add model_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.refusal contains the reason, message.content is None. Naive parser crashes.
  • 12%
    Wrong shape for the API surface. response_format on Responses (should be text.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; use Union[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

Fix #1

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.

parse_helper.pypython
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
Note: Pydantic's 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.
Fix #2

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.

handwritten_schema.pypython
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}")
Note: The 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.
Fix #3

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.

sized_for_structured.pypython
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
Note: Split-stage extraction is often cheaper AND more accurate than one big call — each stage has a simpler schema and less to reason about. Especially valuable for schemas with 10+ fields or deep nesting.

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 in required.
  • 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

Why does Pydantic's schema need extra="forbid"?

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.

What happens if the model produces JSON that violates my 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.

Can I use recursive schemas (a class that references itself)?

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.

Does structured output cost more?

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.

How do I handle enum fields in strict mode?

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.

Related errors