AI & Machine Learning Bug Report

OpenAI strict structured outputs rejects schema: required must include every key

Switching our extraction endpoint to strict structured outputs failed with an invalid schema error. In strict mode every property must be listed in required and additionalProperties must be false. Optional fields became nullable fields and the errors stopped.

Ryan Pham Ryan Pham @ryan_bugs · December 11, 2024 · 6 min read · 8 Views
Banner: OpenAI strict structured outputs rejects a JSON schema with optional fields

Our invoice extraction service asked the model for JSON and validated it with Pydantic afterwards. About 1.5 percent of responses failed validation, so we moved to structured outputs with strict: true, which constrains generation to the schema. The first request in staging failed before generating anything:

openai.BadRequestError: Error code: 400 - {'error': {'message': "Invalid schema for response_format
'invoice': In context=(), 'required' is required to be supplied and to be an array including every
key in properties. Missing 'po_number'.", 'type': 'invalid_request_error',
'param': 'response_format', 'code': None}}

After we added po_number to required, the next error was about additionalProperties on a nested object.

Reproducing it

Any schema with an optional property reproduced it. Our schema was generated by hand from a Pydantic model years ago and had po_number and due_date as optional because many invoices do not have them.

What we ruled out

  • Model support. The model we used supports structured outputs; a minimal schema with only required fields worked.
  • SDK version. We were on a current version; the error came from the API.

Root cause

Strict mode supports a subset of JSON Schema with extra rules. Two of them hit us: every key in properties must appear in required, and every object must set additionalProperties: false. Strict mode has no concept of a field that may be absent. A field that may have no value must be required and allowed to be null.

What is happening under the hood

With strict: true, the API compiles your schema into a grammar and the model can only produce tokens that keep the output valid against it. To make that compilation possible and the output predictable, the schema must be closed and fully specified: every object lists all its keys, all keys are required, and no extra keys are allowed. Optional values are expressed as a union with null, for example "type": ["string", "null"].

Other limits exist too, such as caps on nesting depth and total number of properties, and some keywords are not supported. The documentation lists the supported subset; it is worth reading once before designing schemas.

Environment

Component Version / value
Runtime Python 3.12.7
SDK openai 1.57
Validation Pydantic 2.10
API Chat Completions, response_format: json_schema, strict: true
Model GPT-4o snapshot with structured outputs

Minimal reproduction

schema = {
    "type": "object",
    "properties": {
        "invoice_number": {"type": "string"},
        "total": {"type": "number"},
        "po_number": {"type": "string"},
    },
    "required": ["invoice_number", "total"],      # po_number optional: rejected
}

client.chat.completions.create(
    model=MODEL,
    messages=[{"role": "user", "content": invoice_text}],
    response_format={"type": "json_schema",
                     "json_schema": {"name": "invoice", "schema": schema, "strict": True}},
)

Who is affected

Anyone moving existing schemas to strict mode, especially schemas generated from models with optional fields or from OpenAPI specs.

The fix

We modelled "may be missing" as "required but nullable", and let the SDK generate the schema from Pydantic so the rules are applied consistently.

from datetime import date
from pydantic import BaseModel, ConfigDict

class LineItem(BaseModel):
    model_config = ConfigDict(extra="forbid")
    description: str
    quantity: float
    unit_price: float

class Invoice(BaseModel):
    model_config = ConfigDict(extra="forbid")
    invoice_number: str
    total: float
    currency: str
    po_number: str | None          # required key, value may be null
    due_date: date | None
    line_items: list[LineItem]

completion = client.beta.chat.completions.parse(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Extract invoice fields. Use null when a field is not present."},
        {"role": "user", "content": invoice_text},
    ],
    response_format=Invoice,
)
invoice = completion.choices[0].message.parsed

The SDK helper converts the Pydantic model to a strict schema: all fields required, nullable unions for X | None, and additionalProperties: false on each object. Field defaults like = None are not meaningful in strict mode, so we removed them to avoid confusion.

The system prompt tells the model to use null for missing values. Without that, we saw a few invented PO numbers in testing, because the schema forces the key to exist and the model tried to fill it.

We also handle the refusal case: when the model declines to answer, message.refusal is set and parsed is None.

msg = completion.choices[0].message
if msg.refusal:
    raise ExtractionRefused(msg.refusal)

Alternatives and trade-offs

  • JSON mode without strict schema. Guarantees valid JSON but not the shape. You keep post-validation and retries.
  • Function calling with strict: true. Same schema rules; useful if you already use tools.
  • Sentinel values instead of null (empty string, 0). Avoid: they are indistinguishable from real values later.

How to test the fix

We ran the 1,200-invoice regression set used for the old extractor:

python -m extract.eval --dataset invoices_v4 --mode strict
# schema_valid: 1200/1200   field_accuracy: 0.962   invented_po_numbers: 0

Before: 18 schema failures and 0.951 field accuracy. We also added a unit test that builds the schema from the Pydantic model and asserts that every object has all keys in required.

Confirming in production

  • Validation failures went from about 1.5 percent of requests to zero.
  • The retry path for invalid JSON, which had cost us roughly 2 percent extra tokens, is no longer exercised.
  • A daily sample check compares po_number against the purchase order system; mismatch rate stayed below 0.5 percent, mostly OCR quality.

Regression guard

  • The schema is generated from code in one place; nobody hand-edits JSON schema.
  • The eval set runs on every prompt or model change.
  • Alerts on refusals above a small baseline.

Security implications

Strict schemas reduce one class of risk: downstream code no longer has to handle surprise keys or types from model output. The output is still untrusted data. Values are validated against business rules (totals equal the sum of line items, currencies from an allowlist) before they reach accounting.

Prevention checklist

  • In strict mode: every property in required, additionalProperties: false everywhere.
  • Express optional values as nullable, and tell the model when to use null.
  • Generate schemas from typed models instead of writing them by hand.
  • Handle refusals explicitly.
  • Keep business validation after schema validation.

References

Add a label

#python #openai #json-schema #structured-outputs #pydantic
Written by
Ryan Pham @ryan_bugs

I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.