Nested and Recursive Schemas: The Depth Limit Nobody Documents
There are two ceilings on schema depth and they behave completely differently. One is published, enforced at request time and easy to discover. The other is not a limit at all - it is a gradual change in what a failure looks like, and it has no error message. Two different limits The hard limit is a documented cap in the provider implementation. OpenAIโs Structured Outputs guide has published caps on nesting depth, on total object properties across a schema, and on the combined character length of names and enum values. Those numbers exist, they are enforced with a 400, and they have been raised at least once since the feature launched in August 2024 - so any figure you read anywhere, including here, needs checking against the current docs before you design around it. That is why the section below is a probe rather than a table. The soft limit is the depth at which the model starts putting correct values in the wrong place. There is no announcement. Every response still validates. It is not a property of the provider at all; it is a property of the model, your schema and your document together, which is why nobody can publish a number for it that would mean anything for your case. Why depth fails quietly This is the important idea in the page. Constrained decoding does not remove errors, it relocates them - and nesting is where the relocation is most visible. Without enforcement, a model that loses track of nesting emits unbalanced braces and your parser raises. Loud, immediate, traceable. With enforcement, unbalanced braces are unreachable: the mask will not emit } where the grammar does not allow one. So the model, having drifted, produces the only thing still available to it - a syntactically perfect document with the value in the wrong sub-object. unconstrained, drifted: constrained, drifted: { "parties": [ { "parties": [ { "name": "Acme", { "name": "Acme", "address": { "address": { "city": "Utrecht" "city": "Utrecht", } "postcode": "1011AB" }, dict: """An object nested to the given depth, with a string leaf at the bottom.""" node = {"type": "string"} for i in range(depth): node = { "type": "object", "additionalProperties": False, "required": [f"level_{depth - i}"], "properties": {f"level_{depth - i}": node}, } return node def accepted(depth: int) -> tuple[bool, str]: try: client.chat.completions.create( model=MODEL, max_tokens=64, messages=[{"role": "user", "content": "Fill every level with the word deep."}], response_format={"type": "json_schema", "json_schema": { "name": "probe", "strict": True, "schema": nest(depth)}}, ) return True, "" except BadRequestError as e: return False, str(e)[:220] lo, hi, last_error = 1, 64, "" ok, err = accepted(hi) if ok: print(f"depth {hi} accepted -- no limit found below {hi}") else: while lo < hi: # bisect the first rejected depth mid = (lo + hi + 1) // 2 ok, err = accepted(mid) if ok: lo = mid else: hi, last_error = mid - 1, err print(f"deepest accepted depth: {lo}") print(f"first rejection said: {last_error}") Read the rejection text, not just the depth. It usually names which cap you hit - nesting, property count or character budget - and those three interact: a wide schema hits the property cap long before the nesting cap, so the depth this probe reports for a one-property-per-level schema is an upper bound on what your real schema will get. Run the same probe against every provider you route to. A schema that works on one model and 400s on another is a routine cause of intermittent failures in multi-provider setups, and the answer is usually to design to the tightest limit in your set rather than to branch. โEvery provider you route toโ is the operative phrase, and it is usually longer than people think once failover is counted - Multigridโs provider pages are one way to enumerate that set before you run the probe against each member of it. Recursive schemas Trees, comment threads, nested clauses, org charts. JSON Schema expresses these with a self-reference - "$ref": "#" for root recursion, or a named $defs entry that refers to itself. Hosted strict modes have supported recursive schemas in some form since fairly early, with the same caveat about checking current docs. The failure mode is specific and worth anticipating: a grammar can always continue descending, so a model that starts producing nested nodes has no structural pressure to stop, and the generation runs into max_tokens . You get finish_reason: "length" and a truncated document. Bound it in the schema where you can - a depth integer field with a small enum of allowed values, or an explicit non-recursive leaf variant in an anyOf - and always check finish_reason before parsing. Flattening, and when not to The reliable answer to both limits is to stop nesting and emit a node list with parent references: { "nodes": [ { "id": "n1", "parent_id": null, "kind": "clause", "text": "..." }, { "id": "n2", "parent_id": "n1", "kind": "subclause", "text": "..." }, { "id": "n3", "parent_id": "n1", "kind": "subclause", "text": "..." } ] } Depth becomes one level regardless of how deep the tree is, so both caps stop mattering. Truncation degrades gracefully: a cut-off list is a shorter list rather than a broken tree. And errors become checkable - a dangling parent_id , a cycle, or a duplicate id are all assertions you can write in ten lines, where โthis object is nested under the wrong parentโ is not. The cost is real, so do not do this reflexively. The model must now invent and track identifiers, which is a genuine burden on it, and you must rebuild the tree in code and handle the malformed cases. For two or three levels, nest. Flatten when the depth is unbounded, when the data is recursive by nature, or when the probe above tells you your natural schema does not fit. Top comments (0)
Comments
No comments yet. Start the discussion.