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 site

Take 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 requestStrict / structured output
Unsupported keywordIgnored, silentlyRejected, with a schema error
When you find outAt call time, in your toolAt request time
What you getA value that violates the constraintA 400
Cost of the mistakeA bug in productionA 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: false on every object, including nested ones and anyOf branches.
  • Every property in required; express optionality with a nullable anyOf.
  • 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.
  • strict goes on the tool definition, not on tool_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.