The same JSON schema is accepted in one mode and rejected in the other
30 July 2026
There is a tool for this on the siteTake this tool schema:
{
"type": "object",
"properties": {
"city": { "type": "string" },
"days": { "type": "integer", "maximum": 14 }
},
"required": ["city"]
}
Send it as an ordinary tool definition and it works. The model may pass
days: 30, and your tool will receive it.
Add "strict": true and the request is rejected before the model runs.
Same document. Two failures in opposite directions, and neither is what the schema author had in mind.
What “unsupported” means depends entirely on the mode
The JSON Schema subset a model can be constrained to is smaller than JSON Schema. What happens to the remainder is the part worth internalising:
| Normal request | Strict / structured output | |
|---|---|---|
| Unsupported keyword | Ignored, silently | Rejected, with a schema error |
| When you find out | At call time, in your tool | At request time |
| What you get | A value that violates the constraint | A 400 |
| Cost of the mistake | A bug in production | A failed deploy |
The strict failure is better. It is loud, it is early, and it points at the schema. But you have to know which mode you are in to know which one you will get, and a schema that is fine in one is broken in the other.
The keywords that do not survive
Grouped, because the pattern is clearer than the list:
Numeric bounds. minimum, maximum, exclusiveMinimum,
exclusiveMaximum, multipleOf.
String bounds. minLength, maxLength, pattern.
Array constraints. minItems, maxItems, uniqueItems, contains.
Object constraints. minProperties, maxProperties, patternProperties,
propertyNames.
Conditionals. if, then, else, not, dependentRequired,
dependentSchemas.
The common thread: each expresses a constraint that a grammar cannot enforce while generating token by token. A decoder can guarantee the next token continues a valid integer; it cannot guarantee the integer will end up under fourteen without backtracking, and constrained decoding does not backtrack.
format is a whitelist rather than a convention. The supported set is
date-time, time, date, duration, email, hostname, uri, ipv4,
ipv6, uuid. A format of "phone" or "iso-country" is not in it.
The two rules people fail without noticing
additionalProperties: false on every object, not just the root
The most common rejection, and the reason is that the default is true and
leaving it out is invisible. It applies to:
- nested objects inside
properties - objects inside
items - every branch of an
anyOf - anything in
$defs
A schema carrying it only at the top level looks compliant and is not. This is worth a recursive check rather than a visual one, because a five-level schema with one nested object missing the flag fails in a way that names the whole schema rather than the offending node.
Every property must appear in required
Strict mode has no concept of an optional property. This inverts the usual meaning and is the second most common rejection.
You do not omit a property from required to make it optional. You keep it in
required and make the value nullable:
{
"type": "object",
"additionalProperties": false,
"required": ["city", "days"],
"properties": {
"city": { "type": "string", "description": "City name." },
"days": {
"description": "Forecast length in days, or null for the default.",
"anyOf": [{ "type": "integer" }, { "type": "null" }]
}
}
}
The model can now decline to supply a value without omitting the key, which is what optionality means to a decoder that must emit a complete object.
Recursion cannot be compiled at all
{ "type": "object", "properties": { "child": { "$ref": "#" } } }
A self-reference has no bounded shape, so there is no finite grammar to build. This is not a limitation that a larger model lifts; it is a property of constrained decoding.
Two ways out:
Flatten to a fixed depth. Ugly, explicit, and it works when the real data has a known ceiling.
Take the nested structure as a JSON string. Describe the expected shape in
the property’s description and parse it yourself. Costs one json.loads and is
usually the right answer for genuinely tree-shaped data:
"tree": {
"type": "string",
"description": "A JSON object of the form {\"name\": string, \"children\": [...]}, nested to any depth."
}
External $refs fail for a related reason: nothing fetches a remote schema, so
the reference cannot be resolved and the schema cannot be compiled. Inline it or
move it into $defs.
The wrong instinct: keep the keywords for documentation
The reasonable-sounding position is that maximum: 14 documents intent even if
nothing enforces it, so leave it in and validate separately.
It fails in two ways. In strict mode it is not inert, it is a hard error, so the “harmless documentation” breaks the request. And in normal mode it creates a false sense of enforcement: the next person reading the schema sees a bound and assumes it holds, which is exactly the assumption that lets a violating value through to your tool.
Put the constraint in the description instead. This is the practical
takeaway that survives whichever mode you are in, because the model actually
reads descriptions and does not read maximum:
"days": {
"type": "integer",
"description": "Forecast length in days, between 1 and 14."
}
That does more to keep the value in range than the keyword ever did, in both modes, and it still works after you have removed the keyword to satisfy a decoder. Validate in the tool regardless: a description is guidance, not a guarantee.
What changed recently
Structured outputs moved from output_format to output_config.format. The
old top-level parameter is deprecated API-wide, not only on newer models. Code
carried forward will keep working for now and is worth updating.
strict: true goes on the tool definition, not on tool_choice. A
persistent confusion, and putting it in the wrong place means you are not in
strict mode at all and are back to the silent-ignore behaviour.
Structured outputs are incompatible with citations, and return a 400 when combined. Worth knowing before you design a pipeline around both.
Schemas are compiled and cached for about a day. The first request with a new schema pays a compilation cost. A workload that generates a fresh schema per request never hits that cache and pays it every time, which is a strong argument for keeping schemas static rather than templating them.
Fixing it on a running system
1. Decide which mode you are actually in. Grep for strict and for
output_config. Plenty of teams believe they have strict mode on and do not,
because the flag is in the wrong place.
2. Run the schema through a checker before shipping it, not after the 400. The two rejections above account for most failures and are mechanical to detect.
3. Add additionalProperties: false recursively. A script that walks the
schema and inserts it at every "type": "object" is twenty lines and removes
the whole category.
4. Move constraints into descriptions as you strip keywords. Do both in the same change, or the schema loses information.
5. Validate in the tool regardless of mode. Strict mode guarantees the shape, not the semantics. A well-formed integer that is out of range is still your problem.
When strict mode is the wrong choice
When the schema is genuinely recursive and flattening would distort the data. Take it as a string and parse it, or accept advisory validation.
When you need the rejected keywords more than you need the guarantee. A
pattern that catches 90% of malformed input at the model level may be worth
more to you than a shape guarantee, if your tool validates thoroughly anyway.
When the schema changes per request. The compilation cost on every call is real, and strict mode’s benefit assumes a stable schema.
When you are using citations, where the two features are incompatible.
Strict mode is a good default for a stable tool surface. It is not free, and “always turn it on” is advice that ignores the cases above.
The short version
- Unsupported keywords are ignored in normal mode and rejected in strict mode. Same document, opposite failures.
additionalProperties: falseon every object, including nested ones andanyOfbranches.- Every property in
required; express optionality with a nullableanyOf. - Recursion cannot be compiled. Flatten it, or take it as a JSON string.
- Only ten string formats are understood.
- Put the constraint in the description. The model reads that and does not
read
maximum. strictgoes on the tool definition, not ontool_choice.
The strict mode schema checker separates
what a constrained decoder rejects outright from what it merely ignores, and
walks nested objects rather than only the root, which is where the
additionalProperties failures actually live.