●SHEETS — Fill with Gemini expands to 28 more languages, so you can build spreadsheets in your own language with natural prompts●ALPHAEVOLVE — Gemini Enterprise makes AlphaEvolve generally available, autonomously discovering optimized code solutions●SPARK — Gemini Spark, a personal AI agent for macOS, organizes folders and runs workflows across Workspace●OMNI — Gemini Omni blends text, photos, and video for high-quality creation, plus custom AI avatars of you●GEMINI35 — Gemini 3.5 Pro slips to July 17 for a full rebuild, introducing a 2M-token context and Deep Think●DEEPTHINK — The 3.5 Pro rebuild targets better math reasoning, SVG scene generation, and image quality●SHEETS — Fill with Gemini expands to 28 more languages, so you can build spreadsheets in your own language with natural prompts●ALPHAEVOLVE — Gemini Enterprise makes AlphaEvolve generally available, autonomously discovering optimized code solutions●SPARK — Gemini Spark, a personal AI agent for macOS, organizes folders and runs workflows across Workspace●OMNI — Gemini Omni blends text, photos, and video for high-quality creation, plus custom AI avatars of you●GEMINI35 — Gemini 3.5 Pro slips to July 17 for a full rebuild, introducing a 2M-token context and Deep Think●DEEPTHINK — The 3.5 Pro rebuild targets better math reasoning, SVG scene generation, and image quality
When responseSchema Can't Do $ref: Handling Recursive Schemas in Production with responseJsonSchema
Gemini's responseSchema is an OpenAPI subset with no $ref or $defs, so it can't express shared definitions or recursion. Here's how I moved to responseJsonSchema to reuse localized fields and handle a recursive category tree in production.
The first wall I hit trying to have Gemini classify wallpaper categories was that I couldn't express a category tree in the schema at all. Nature to Mountain to Snowy Mountain: a parent holds children, which hold their own children, and the depth varies by image. When I tried to write a self-reference in responseSchema, it simply wouldn't accept $ref. So I hand-inlined three levels and gave up on anything deeper.
What dissolved that compromise was responseJsonSchema. It's a separate field from responseSchema, and it understands JSON Schema's $ref and $defs. You can define something once and reuse it, and you can declare recursion through self-reference. As an indie developer dealing with multilingual, nested structured output, that difference maps directly onto how maintainable the whole pipeline is. This piece walks through the differences, the migration, and the traps I actually stepped on.
What responseSchema can't express
responseSchema accepts a subset of the OpenAPI 3.0 Schema object. You get type, properties, items, enum, nullable, propertyOrdering, and the later-added anyOf. But it does not interpret $ref (a reference to another definition) or $defs (a place for reusable definitions), which are at the heart of JSON Schema.
That gap bites in two situations.
First, when you want to reuse the same shape in several places. In my case, a LocalizedText object representing a Japanese/English pair is needed in three spots: the category name, the description, and the tags. With responseSchema you inline it three times, the schema grows vertically, and every time you tweak the required fields you have to fix all three without missing one.
Second, recursion. A structure like a category tree, where a node contains itself as a child, can't be expressed without references. Expanding to a fixed depth is a workaround, but with variable-depth data it breaks somewhere eventually.
Aspect
responseSchema
responseJsonSchema
Schema dialect
Subset of OpenAPI 3.0
JSON Schema ($ref/$defs)
Reusing shared definitions
No (inline only)
Yes, via $defs + $ref
Recursion / self-reference
No
Yes
propertyOrdering
Supported
No such concept
Using both
Mutually exclusive (one at a time)
Define the localized object once with $defs
Start with reuse. Using google-genai (the newer Python SDK), define LocalizedText once in $defs and reference it from several places with $ref.
from google import genaifrom google.genai import typesclient = genai.Client(api_key="YOUR_API_KEY")# Define LocalizedText once, reference it from multiple placesschema = { "type": "object", "$defs": { "LocalizedText": { "type": "object", "properties": { "ja": {"type": "string"}, "en": {"type": "string"}, }, "required": ["ja", "en"], } }, "properties": { "name": {"$ref": "#/$defs/LocalizedText"}, "description": {"$ref": "#/$defs/LocalizedText"}, "tags": { "type": "array", "items": {"$ref": "#/$defs/LocalizedText"}, }, }, "required": ["name", "description", "tags"],}resp = client.models.generate_content( model="gemini-2.5-flash", contents="Give a snowy mountain wallpaper a category name, description, and tags in Japanese and English.", config=types.GenerateContentConfig( response_mime_type="application/json", response_json_schema=schema, ),)print(resp.text)
There's a single definition of LocalizedText. If I later decide to require zh alongside ja/en, I edit exactly one block inside $defs. Back when I inlined it under responseSchema, I applied the same change by hand in three places. The more definitions you accumulate, the more that difference compounds.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you've been inlining the same localized object over and over because responseSchema can't do $ref, you'll learn to define it once in $defs and reuse it everywhere
✦You'll declare a category tree of arbitrary depth using a self-referencing $ref instead of the fixed-depth workaround, and receive it with working code
✦You'll be able to decide between responseSchema and responseJsonSchema per task, knowing the feature gaps and the traps in each
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Declare a recursive category tree with a self-referencing $ref
Now the main event: recursion. A category node holds a label plus an array of child nodes, and each child is itself a CategoryNode. You write that self-reference with $ref.
tree_schema = { "type": "object", "$defs": { "LocalizedText": { "type": "object", "properties": { "ja": {"type": "string"}, "en": {"type": "string"}, }, "required": ["ja", "en"], }, "CategoryNode": { "type": "object", "properties": { "label": {"$ref": "#/$defs/LocalizedText"}, # Each child is a CategoryNode itself -- this is the recursion "children": { "type": "array", "items": {"$ref": "#/$defs/CategoryNode"}, }, }, "required": ["label", "children"], }, }, "properties": {"root": {"$ref": "#/$defs/CategoryNode"}}, "required": ["root"],}resp = client.models.generate_content( model="gemini-2.5-flash", contents="Build a wallpaper category tree up to three levels, like Nature -> Mountain -> Snowy Mountain.", config=types.GenerateContentConfig( response_mime_type="application/json", response_json_schema=tree_schema, ),)
The key point is that the schema doesn't hard-code the depth. When I want three levels, I ask for them in the prompt, but the schema itself permits any depth. If the real data turns out deeper or shallower, I don't rewrite the schema. With the fixed-depth inline approach, a tree deeper than expected had its leaves quietly chopped off.
Since the depth is variable, walking the returned tree recursively is the natural fit.
Being able to pin the tree shape in the schema keeps downstream processing simple. For column design when writing results to Sheets, and for the field-order drift that can break that, pair this with what I covered in pinning field order with propertyOrdering.
Validate to guarantee the shape
Structured output doesn't mean "I passed a schema, so it's always the right shape." Before anything reaches production ingestion, I always run local JSON Schema validation. The nice part is that the schema you hand to responseJsonSchema is ordinary JSON Schema, so you can revalidate it with the jsonschema library as-is.
import jsonfrom jsonschema import Draft202012Validator, ValidationErrordef parse_validated(resp_text: str, schema: dict) -> dict: data = json.loads(resp_text) validator = Draft202012Validator(schema) errors = sorted(validator.iter_errors(data), key=lambda e: e.path) if errors: # Surface just the first error concretely, for retry decisions first = errors[0] path = "/".join(str(p) for p in first.path) or "(root)" raise ValidationError(f"{path}: {first.message}") return data# Reuse the exact same schema you passed to the APIdata = parse_validated(resp.text, tree_schema)
The quietly valuable part is that the schema passed to the API and the schema used for validation are the same object. The OpenAPI subset behind responseSchema can't be fed straight into jsonschema, so you had to maintain a separate validation schema or run a conversion. After migrating, that double bookkeeping disappears. For running validation and suppressed retries while measuring them, I wrote up the details in an operations note on structured output silently drifting off schema.
Common mistakes and traps
Here are the ones I actually hit mid-migration.
Setting responseSchema and responseJsonSchema together. These two are exclusive. Passing both to GenerateContentConfig errors out. When migrating, be sure to remove the old response_schema. I forgot to comment it out at first, got a 400, and spent a while chasing the cause.
Trying to bring propertyOrdering along.propertyOrdering is a responseSchema concept; responseJsonSchema has no equivalent. If you have a hard requirement to control field order strictly, that's the one point where responseSchema still has the edge. For tasks where order is essential, not migrating is a legitimate choice.
Assuming every JSON Schema keyword works.$ref/$defs are handled, but not every JSON Schema keyword (complex conditionals, some format constraints) is guaranteed to take effect. Check the supported keywords in the official docs, and don't offload elaborate constraints entirely to the schema; back them with downstream validation. I split the roles as "schema for the skeleton, jsonschema for the fine constraints."
Asking a low-effort model for a deep tree. A deeply recursive schema is a demanding generation. Ask an extremely shallow-reasoning configuration for a deep tree and it may cut children off partway. When deep structure matters, choosing a model with reasoning headroom was more stable. For switching types by input kind itself, my operations note on anyOf discriminated unions is a useful reference.
How to choose between them
My rule is simple. Reach for responseJsonSchema when you need shared-definition reuse or recursion; reach for responseSchema when you need strict field-order pinning. For flat structures needing neither, use whichever you're comfortable with. For a case like mine, multilingual and nested data in indie development, the payoff from centralizing definitions with $defs was large, and migrating was the right call.
One caveat: the support status described here is accurate as of writing. Gemini's API moves fast, so before you build, confirm the supported keywords and per-model availability in the official docs once. Start by carving your deepest schema out into $defs, moving it onto responseJsonSchema, and getting a single call through. The relief of watching the inline duplication vanish is what will carry the rest of the migration.
Thanks for reading.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.