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.
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_numberagainst 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: falseeverywhere. - 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
- OpenAI structured outputs guide — https://platform.openai.com/docs/guides/structured-outputs
- Supported schemas in strict mode — https://platform.openai.com/docs/guides/structured-outputs#supported-schemas
- Pydantic model config, extra fields — https://docs.pydantic.dev/latest/api/config/
Add a label
Embed this post
<iframe src="https://hibug.me/openai-strict-json-schema-required-every-key" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Related Stories
Receipt OCR with a vision LLM cost 6x the estimate: full-resolution photos
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.