Skip to content

Migrating Structured Output Schemas Between Providers

10 min read · updated August 11, 2026

Structured output is advertised as “pass a JSON Schema”. No provider accepts JSON Schema. Each accepts a subset, the subsets differ, and in at least one case they require opposite things about the same keyword — so a schema that is valid and working on one side can be rejected outright on the other.

Nobody accepts JSON Schema

The reason is mechanical rather than arbitrary. These features work by constrained decoding: the schema is compiled into a state machine that restricts which tokens the sampler may emit at each step, so the output is valid by construction rather than validated afterwards. Any keyword that cannot be expressed as a decoding constraint therefore cannot be supported.

That single fact predicts most of the omissions. Structural keywords — type, properties, required, enum, anyOf, $ref — map cleanly onto a grammar and are widely supported. Value-range assertions — minimum, maximum, multipleOf, minLength, maxLength, pattern — are much harder to express token-by-token and are commonly dropped. Recursive schemas, where a definition refers to itself, produce an unbounded grammar and are frequently rejected outright.

Understanding that boundary is more useful than memorising any particular provider’s list, because the list changes as the decoders improve. What does not change is that the constraints expressible in a grammar are supported first, and everything else arrives late or not at all.

The keyword conflicts

These are the categories that actually break a migration, in rough order of how often they do.

  • Optional properties. Several implementations require every property to appear in required. Optionality is then expressed by widening the type to include null — { "type": ["string", "null"] } — rather than by omitting the key from required. Ported to a provider that treats required normally, those nullable unions still work but change your parsing: you now receive explicit nulls where you previously received absent keys.
  • Value constraints. A schema with minLength, maximum or pattern may be accepted with the constraint silently ignored, or rejected as an unsupported keyword. Both outcomes are bad and they are bad in different ways: silent acceptance means your validation is not happening and you do not know it.
  • Recursion. A self-referencing definition — a comment tree, a nested expression — is supported by some implementations and rejected by others. There is no workaround beyond flattening to a fixed maximum depth, which changes your data model.
  • Root type. Some implementations require the root to be an object, so a schema whose root is an array or a anyOf must be wrapped in a single-property object. Harmless but it changes the shape your parser expects.
  • Depth and breadth limits. Maximum nesting depth and total property count are capped, and the caps differ. A schema built by generating from an ORM model will hit these long before a hand-written one does.
Supported-keyword lists move in one direction — they grow — but they grow at different rates per provider. Take the current sets from Anthropic’s structured outputs documentation and OpenAI’s structured outputs guide before you write the schema, not after the first rejection.

The one that is genuinely contradictory

additionalProperties is the keyword worth knowing by name, because the requirements are not merely different — they conflict.

OpenAI’s strict mode requires "additionalProperties": false on every object in the schema. Omit it and the request fails validation with a message naming the object that is missing it. Google’s response-schema feature, which is defined against an OpenAPI-derived subset rather than JSON Schema proper, historically rejects additionalProperties as an unrecognised field. Anthropic’s structured output accepts additionalProperties only when the value is false, which happens to be compatible with the first requirement.

So a schema authored for a strict-mode implementation cannot be sent unmodified to a schema-subset implementation that rejects the keyword, and vice versa. There is no single literal document that satisfies both. The only workable answer is to keep the schema as a value in your code and emit a per-provider dialect from it — which is exactly the kind of translation that belongs inside the provider-specific half of an adapter layer.

// One canonical schema; two dialects emitted from it.
const person = {
  type: "object",
  properties: {
    name:  { type: "string" },
    email: { type: ["string", "null"] },
    plan:  { type: "string", enum: ["free", "pro", "enterprise"] },
  },
  required: ["name", "email", "plan"],
} as const;

// Dialect A: strict mode wants additionalProperties: false everywhere.
function strictDialect(s: object): object {
  return walkObjects(s, (o) => ({ ...o, additionalProperties: false }));
}

// Dialect B: OpenAPI-subset validators reject the keyword entirely.
function subsetDialect(s: object): object {
  return walkObjects(s, ({ additionalProperties, ...rest }) => rest);
}

Writing the intersection dialect

If you would rather maintain one document than a transformer, the intersection is small but usable. Restrict yourself to: type with the six basic values, properties, required listing every key, enum for closed sets, items for arrays, description on everything, and a root that is an object. Express optionality as a nullable union. Keep nesting to three levels or fewer.

Then move every constraint you had to drop into two places: the description strings, which the model does read and does largely honour, and a validator in your own code that runs on the parsed result. A maxLength the decoder cannot enforce is not lost if the description says “at most 40 characters” and a post-hoc check rejects the handful of violations. That two-layer arrangement is more portable than any schema, because the second layer is yours and does not migrate.

The one thing not to do is generate the schema from a type definition and ship whatever the generator emits. Type-to-schema generators produce exactly the keywords that trip these limits — format annotations, numeric bounds, recursive references from circular model relationships — and a generated schema that works today breaks on the next model added to your fleet.

What the guarantee actually covers

Every one of these features guarantees syntactic conformance, not semantic correctness. The output will parse and match the shape. It may still be wrong, and there are three specific ways the guarantee does not hold that you must handle regardless of provider.

  • Truncation. If generation hits the output cap mid-object, you get invalid JSON. Constrained decoding cannot prevent this because the constraint governs which token comes next, not how many tokens remain. Check the stop reason before parsing; a length stop means the payload is incomplete by definition.
  • Refusal. A safety refusal is not schema-shaped, and the response carries a distinct stop reason to tell you so. Branch on it before reading the content.
  • First-request latency. A schema the provider has not seen must be compiled into a decoder, which adds latency on the first request and is then cached for some window. A latency test that sends each schema once measures compilation, not steady state.

When a schema is rejected outright rather than producing wrong output, the error message is specific enough to act on — tracing an “Invalid schema” failure back to the offending keyword is a mechanical process once you know where to look.