response_format json_schema: How OpenAI's Structured Output Type Works
10 min read · updated August 11, 2026
response_format with type: "json_schema" and strict: true is the setting that makes the model’s output conform to your schema by construction rather than by persuasion. What you give up for it is most of JSON Schema.
The request, in full
Nothing about this is split across snippets — the whole thing is one request body. The task is extracting a support ticket from free text.
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-2024-08-06",
"messages": [
{ "role": "system", "content": "Extract the ticket fields from the user message." },
{ "role": "user", "content": "hi, order 10482 arrived cracked, this is the second time. [email protected]" }
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "support_ticket",
"strict": true,
"schema": {
"type": "object",
"properties": {
"order_id": { "type": "string" },
"email": { "type": ["string", "null"] },
"category": { "type": "string", "enum": ["damage", "delay", "billing", "other"] },
"repeat": { "type": "boolean" },
"summary": { "type": "string" }
},
"required": ["order_id", "email", "category", "repeat", "summary"],
"additionalProperties": false
}
}
}
}'The nesting is the part people get wrong on the first attempt. There are two levels called schema-ish things: response_format contains a json_schema object, and that object contains name, optional description, strict, and a schema key holding the actual JSON Schema. Putting your schema directly under json_schema is the most common malformed request in this area.
name is required and must match ^[a-zA-Z0-9_-]+$. It is not cosmetic: the model sees it, so a name like support_ticket is a small piece of free prompt engineering and schema1 is a small piece of wasted context. The optional description is likewise given to the model and is the right place to say what the object is for.
strict: true is what turns the schema from a suggestion into a constraint. Without it — or with it omitted — the schema is advisory and the model may produce something that does not validate. With it, the serving stack constrains decoding so that only tokens which keep the output a valid instance of the schema can be sampled. That is why the guarantee is structural: an invalid token is not merely unlikely, it is unreachable.
The strict-mode schema subset
That constrained decoding is why the accepted schema language is a subset. Every construct has to be compilable into a decoding constraint ahead of time, and the ones that cannot be are rejected rather than ignored. The rules that bite in practice:
- The root must be an object. Not an array, not a union. If you want a list, wrap it:
{ "items": [...] }. - Every object needs
additionalProperties: false. Every one, at every level of nesting, not just the root. Omitting it on a nested object is the single most common rejection. - Every property must be listed in
required. There are no optional fields. This surprises people, and the workaround is a nullable type:{ "type": ["string", "null"] }as used foremailabove. The key is always present; its value may benull. - Supported types are
string,number,integer,boolean,object,array,enumandanyOf.anyOfmay not be used at the root. - Most validation keywords are unsupported —
minLength,maxLength,pattern,format,minimum,maximum,minItemsand friends. They constrain values rather than structure, and the decoder cannot enforce them token by token. Validate those yourself after parsing. $refand$defsare supported for reuse, including recursive references via{"$ref": "#"}, subject to documented limits on total properties, nesting depth, enum size and overall schema size.
A schema outside the subset does not degrade gracefully; it returns a 400 naming the offending path. The rules are the same ones that apply to strict mode on function parameters, which is the same machinery pointed at a tool definition instead of a response, so a schema that works in one place works in the other.
strict: true require a model that supports them — gpt-4o-2024-08-06 and later on the 4o line, and not every model since. The limits on schema size and nesting are documented and have been raised more than once. OpenAI’s structured outputs guide carries both the model list and the current limits.One latency note that is easy to misdiagnose: the first request with a new schema is slower, because the schema is processed into a decoding constraint before generation starts. Subsequent requests with the identical schema reuse that work. If you build schemas dynamically and they differ per request, you pay that cost every time, which is a good reason to keep schemas static and put the variation in the prompt.
Reading the response
The response is an ordinary chat completion. The object you asked for is in content, as a string:
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "{\"order_id\":\"10482\",\"email\":\"[email protected]\",\"category\":\"damage\",\"repeat\":true,\"summary\":\"Order arrived cracked; second occurrence.\"}",
"refusal": null
},
"finish_reason": "stop"
}]There is no parsed field. content is a JSON-encoded string and you call JSON.parse or json.loads on it yourself. The official SDKs offer a helper that does the parse and, given a Pydantic model or a Zod schema, generates the schema and returns a typed object — client.beta.chat.completions.parse in Python and the equivalent in the Node SDK. Using it removes the two places where hand-written integrations go wrong: schema drift between your type definition and your JSON, and forgetting to handle the two fields below.
Refusals and truncation
Strict mode guarantees the schema, not that you get an object at all. Two documented outcomes return something else, and both are well-formed responses rather than errors, so nothing throws.
Refusal. If the model declines the request, it cannot express that inside your schema — your schema has no field for “I will not do this”. So the refusal goes in a sibling field and content is null:
"message": {
"role": "assistant",
"content": null,
"refusal": "I'm sorry, I can't help with that request."
}Code that reads content without checking refusal will call JSON.parse(null) and throw something unhelpful several frames away from the cause. Check refusal first, always.
Truncation. If generation hits the token budget mid-object, you get finish_reason: "length" and a content string that is valid JSON up to the point it stops and invalid overall. Constrained decoding guarantees the output is on a path to a valid instance; it cannot guarantee the path is completed within your budget. So branching on finish_reason is not optional here — it is the only thing distinguishing a whole object from half of one. Schemas that can produce long arrays are the usual culprits, and leaving the budget unset does not make this go away.
How it differs from json_object
The older response_format: { "type": "json_object" } is still accepted and does much less. It guarantees that the output parses as JSON. It says nothing about which keys, which types or which shape, so you can get a valid object with every field renamed, a string where you wanted a number, or an extra field you never asked for. It also carries a documented requirement that your prompt must instruct the model to produce JSON — if the word does not appear, the request errors.
One design consequence of constrained decoding is worth building into your schemas rather than discovering later. Generation is still sequential, and the decoder emits your object’s fields in the order the schema declares them. So a field the model must reason its way to should come after the fields it reasons from, and a schema that puts answer first and evidence second is asking the model to commit to a conclusion before it has written down anything supporting it. Ordering { "evidence", "reasoning", "answer" } gives the later fields the earlier ones as context, for free, because they are already in the sequence. It is the same effect chain-of-thought prompting relies on, expressed in the schema instead of the prompt, and it costs nothing but the tokens you were going to spend anyway.
The two response formats exist for different eras and there is no reason to choose json_object on a model that supports json_schema. Where a model does not support structured outputs, json_object plus your own validation and a retry loop is the fallback, and it is a genuinely weaker guarantee: you are checking after the fact instead of constraining during. Budget for a retry path if you go that way, because you will need it.