●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Evolving Gemini API Structured Output Schemas in Production — Design Notes from an Indie App
How I rebuilt the JSON contract layer for a Gemini-powered recommendation feature in a long-running indie app — Dual-Emit, Sunset protocol, and a Python compatibility checker.
About a month ago, one of the wallpaper apps I run as a solo developer started throwing errors at 5am from a Gemini-powered recommendation endpoint. The Crashlytics alert woke me up. The error trail read Type 'undefined' is not assignable to type 'string' — a Zod validation failure on the client side, and the crash rate was already climbing.
The cause was almost embarrassing. The day before, I had added an optional field to the response_schema in Google AI Studio. Gemini happily generated the new field, but somewhere in the process, a different optional field changed its "unset" behavior from undefined to an empty string. An undocumented behavior shift. Around 20% of the users who hit the endpoint that day across my 50-million-download app catalog were affected. AdMob eCPM had been steady, but recommendation-driven session retention dropped 12% overnight, equivalent to about ¥180,000 in monthly impression revenue evaporating.
What I really learned was this: the JSON that Gemini returns is not just data. It is a contract you make with every installed client — and it is a fragile contract, because a third-party model update can rewrite it without warning. This article is a record of the design I rebuilt after that incident: how I now treat schemas as contracts, the Dual-Emit pattern I've been running since April 2026, the Sunset protocol that follows it, and the Python compatibility checker that keeps the whole thing honest.
What broke, in numbers
I'm an indie developer shipping iPhone and Android apps as Dolice. I run several wallpaper, healing, and manifestation apps, and Gemini API has been doing recommendation tagging and short intro-text generation across most of them since early 2026.
The damage that morning, in raw figures:
Apps affected: 6 of 18 (the ones using Gemini-driven recommendations)
Session retention drop: 12.4% average, worst single app 18.7%
AdMob impression loss: ~36,500 impressions in 24 hours
Full recovery time: 1 hour to fix code, ~23 hours total including App Store review and staged rollout
The painful part wasn't the incident itself — it was the root-cause investigation. All I had were client-side Zod failure logs. The actual raw Gemini responses were essentially gone. Cloudflare Workers retained logs for 7 days, but the structured output bodies were being stripped to save space. Half of the post-incident work was about fixing this observability gap, not the schema bug.
Treat the schema as a contract — separate the internal IR from the public surface
The first thing I changed was the assumption that "Gemini's response equals our API response." I split the model into three layers: a permissive input schema that accepts whatever Gemini sends, an internal IR (Internal Representation) that the rest of my code is allowed to depend on, and an external schema that defines the actual public contract with the client.
// schemas/recommendation.tsimport { z } from "zod";// Layer 1: Input schema — absorb Gemini-side variance here.export const GeminiRawRecommendation = z.object({ theme_tag: z.string().min(1).max(64), // accept null / undefined / empty string and normalize at the boundary subtitle: z.string().optional().nullable().transform((v) => v?.trim() || null), confidence: z.number().min(0).max(1).default(0.5),}).passthrough(); // never reject if the model adds extra fields// Layer 2: Internal IR — the invariants the rest of the app may rely on.export const Recommendation = z.object({ theme: z.string().min(1).max(64), subtitle: z.string().nullable(), // explicit null, never undefined confidence: z.number().min(0).max(1), source: z.enum(["gemini-3.2", "gemini-3.1-fallback", "cached"]),});// Layer 3: Public contract — versioned, stable, what the client sees.export const RecommendationV2 = z.object({ schemaVersion: z.literal("v2"), theme: z.string(), subtitle: z.string().default(""), // clients are guaranteed never to see undefined confidence: z.number(),});
Three things matter here. First, the input schema uses passthrough() deliberately — if Gemini sneaks in a new field, you don't want a 500 error. Second, the internal IR decides once and for all that "absent" means null, not undefined and not "". Letting both leak past the boundary is what hurt me. Third, the public contract gets an explicit schemaVersion literal so the client can pick a parser instead of guessing.
Normalizing at the boundary is the single change that paid off most for me. Once you settle the shape at the edge, everything inside becomes far easier to live with.
✦
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
✦Two-layer Zod schema design (Gemini-facing input, internal IR, public contract) with full Cloudflare Workers source
✦Day-by-day log of a 2-week Dual-Emit migration, including the rollout cutoff threshold and how I picked it
✦Python compatibility checker that joins sampled response logs with Swift/Kotlin client code to flag breaking changes before they ship
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.
Dual-Emit — return v1 and v2 from the same response
You cannot wait for 100% of installed clients to update before flipping the schema, especially as an indie developer. My catalog has roughly 22% Day-30 retention, which means a meaningful fraction of users may not open the app for weeks. So during migration, the server returns both v1 and v2 in the same response body.
// workers/recommend.tsimport { RecommendationV2 } from "../schemas/recommendation";export async function handleRecommend(req: Request, env: Env) { const raw = await callGeminiAPI(env, prompt); const ir = await normalizeToIR(raw); // input -> internal IR const v1Payload = toV1Shape(ir); const v2Payload = RecommendationV2.parse({ schemaVersion: "v2", theme: ir.theme, subtitle: ir.subtitle ?? "", confidence: ir.confidence, }); return Response.json({ // old clients keep reading the top-level fields ...v1Payload, // new clients read the v2 key v2: v2Payload, _meta: { generated_at: new Date().toISOString(), ir_source: ir.source, }, });}
New clients prefer the v2 key when it exists; old clients don't know about it and ignore the extra payload. They run side by side for a defined window.
I chose two weeks. Retention curves for my older app versions show monthly active users dropping from ~67% to ~8% by Day 14 after a forced update prompt. As a rule of thumb I use "Day 7 is observation, Day 14 is the safe cutoff."
Workers implementation and the observability fix
The trap in implementing Dual-Emit is forgetting to log all three layers — raw, IR, and public output. That was the original incident's root cause for me. The fix was sampled logging to Cloudflare R2: 1% of requests get their raw response, IR, v1 output, and v2 output written as JSON Lines.
async function logSample(env: Env, payload: SamplePayload) { // 1% sampling — enough to debug, cheap enough to keep if (Math.random() > 0.01) return; const date = new Date().toISOString().slice(0, 10); const key = `gemini-samples/${date}/${crypto.randomUUID()}.jsonl`; const line = JSON.stringify({ ts: Date.now(), schema_version: "v2", gemini_raw: payload.raw, ir: payload.ir, v1_output: payload.v1, v2_output: payload.v2, // critical: always include app version and locale app_version: payload.appVersion, locale: payload.locale, }); await env.SAMPLES.put(key, line, { httpMetadata: { contentType: "application/x-ndjson" }, });}
R2 with a 30-day retention policy costs me a few cents per month. Compared to the AdMob revenue at stake, the observability spend is trivial.
Sampling rate, in practice
Day 1 at 1%, then drop to 0.1% once stable. For my apps (tens of thousands of requests per day per app), 0.1% still produces dozens of samples a day — plenty for production debugging.
Always log app_version and locale
I strongly recommend this. Without it, you cannot distinguish "the Japanese-locale build of app X" from "everything globally." I lost half a day during the original incident because my samples didn't carry these fields, and I couldn't tell where the breakage was concentrated.
Compatibility checker — find breaking changes before you ship
Before shipping a new schema, I want to know what fields my client code actually depends on, and what values are currently flowing through. This Python script joins the two:
# scripts/schema_compat.pyimport jsonimport refrom pathlib import Pathfrom collections import Counter# 1. Extract field references from client code (Swift + Kotlin)FIELD_RE = re.compile(r"\.recommendation\.(\w+)|response\[['\"](\w+)['\"]\]")def extract_field_usage(app_dirs: list[Path]) -> Counter: usage = Counter() for d in app_dirs: for ext in ("*.swift", "*.kt"): for f in d.rglob(ext): text = f.read_text(encoding="utf-8", errors="ignore") for m in FIELD_RE.finditer(text): field = m.group(1) or m.group(2) if field: usage[field] += 1 return usage# 2. Compute the actual distribution of values from sampled R2 logs.def sample_field_distribution(log_dir: Path) -> dict[str, dict]: dist = {} for f in log_dir.rglob("*.jsonl"): for line in f.read_text().splitlines(): row = json.loads(line) for k, v in (row.get("v1_output") or {}).items(): d = dist.setdefault(k, {"null": 0, "empty": 0, "value": 0}) if v is None: d["null"] += 1 elif v == "": d["empty"] += 1 else: d["value"] += 1 return dist# 3. Classify each proposed change.def classify_change(field, removed, became_required, usage, dist): used = usage.get(field, 0) nulls = dist.get(field, {}).get("null", 0) total = sum(dist.get(field, {}).values()) or 1 null_ratio = nulls / total if removed and used > 0: return f"BREAKING: field '{field}' removed but referenced {used} times" if became_required and null_ratio > 0.05: return f"BREAKING: '{field}' required but {null_ratio:.1%} of past responses were null" return "SAFE"
It's rough, but for my scale it's been enough. The first time I ran it, the subtitle field had a 14% null rate in production — far above the 5% threshold I had picked. Knowing "I cannot make this required without breaking 14% of responses" before I write the migration is much cheaper than learning it the way I did the first time.
Sunset protocol — how to retire v1 without scaring anyone
Once Dual-Emit has been live for two weeks, you have to make a call. My rule:
Day 0 — Ship Dual-Emit. Submit the client update that reads v2 to the stores.
Day 7 — Add _meta.deprecated = true and _meta.sunset_at = "YYYY-MM-DD" to v1 responses.
Day 10 — Check the version distribution of clients still reading v1. Project whether 90% will be on the new version by Day 14.
Day 14 — If the projection holds, stop emitting v1. The response now contains only v2 and the meta block.
If Day 14 falls short of 90%, extend the sunset to Day 21 and trigger an in-app update banner.
I chose 90% because my historical store update completion rates land between 91% and 94% over 30 days when App Store review goes smoothly. Calibrate this number to your own update curves before you trust it.
I also recommend reading _meta.sunset_at on the client and rendering a soft "please update" dialog when the sunset date is within seven days. Five lines of code. It is not a forced upgrade, so it does not hurt the UX, but it noticeably accelerates migration in my own measurements.
Why an indie developer ever starts thinking about contracts
Honestly, I used to think schema versioning was a problem for enterprise-scale services, not solo developers. While an app is something you write and use yourself, the JSON Gemini returns is just "something that parses," and "contract" feels like an unnecessary word.
What changed my mind was running the same feature long enough that other devices and older builds started depending on its responses. Change the schema once, and a client you shipped weeks ago crashes quietly. The radius of "I'll just fix it myself" had grown past what one indie developer can hold in their head.
Treating a schema as a contract is not about getting one response right. It is about keeping a promise — to users you will never meet, and to the code you yourself shipped in the past — that you will not break it.
Why schemas always change
So far we have looked at surviving a single breaking change. Let me switch angles: why do changes keep happening at all, and how do you make the act of changing safe and repeatable?
It is essentially impossible to ship a schema that survives a year unchanged. The reasons are predictable:
User expectations shift — "category was enough; now we need a sub-category"
Model capability shifts — a new Gemini version can return a richer shape
Downstream needs shift — DB indexes, frontend display
You spot the original mistake too late — tags: string should have been tags: string[]
These are all things you only notice once 10,000+ records have flowed through production. The lesson: do not aim for "never change." Aim for "change without breaking anything."
Three layers — Gemini schema, stored schema, domain model
Earlier I described the "contract" as two layers — the input IR and the public surface. For something you run for a long time, adding a stored layer in between gives you three, and it keeps the blast radius of any change confined to a single layer. Each layer changes on its own cadence, and a change in one does not ripple into the others.
[Gemini schema] → JSON Schema sent to the API
↓ parse
[Stored schema (versioned)] → type that lands in Postgres / Firestore / KV
↓ map
[Domain model] → type the application code reads
Splitting them this way keeps each kind of change from cascading:
Change only the Gemini schema → existing stored data stays as-is; new records use the new schema
Change only the stored schema → migration is needed, but Gemini and the app code stay still
Change only the domain model → application-level concern, storage untouched
The single most important rule: every stored record carries schemaVersion. Without it, code cannot tell which shape it is reading. Records I stored without version tags have cost me, in retrospect, far more time to read back than every other migration cost combined.
Four kinds of change, four levels of difficulty
Schema changes fall into four buckets that demand very different procedures.
1. Add a field — easy
Add a non-required field. Mark it optional in both the Gemini schema and the stored schema. Old data simply has the field as null. Risk is low; running V1 and V2 in parallel is unnecessary.
2. Change a type — moderate
Going from tags: string to tags: string[] is a real version bump.
Existing data is converted by a background job in stages. The non-negotiable: keep both V1 and V2 readable during the transition. A "convert everything in one go" plan loses every record on the day of failure.
3. Rename a field — handle with care
Renaming title to headline is a four-phase procedure: add the new field, write both, switch reads to the new field, retire the old. This is the classic Expand-Contract pattern that Stripe and GitHub use on their public APIs.
// Phase 1: write bothfunction write(record: { headline?: string; title?: string }) { const value = record.headline ?? record.title; return { headline: value, title: value }; // keep for old readers}// Phase 2: stop reading the old field// Phase 3: stop writing the old field// Phase 4: drop the old field from the schema
Each phase needs at least a week of soak time. Production exposes the readers you forgot about; the soak window is your only chance to discover them. New Gemini requests can return headline from Phase 1, but the storage layer still carries both fields for the duration.
4. Retire a field — hardest
Removing a field demands the most caution. Are you really sure no downstream consumer reads it? My standing rule is at least three months of deprecation announcement before deletion. Channels: a JSDoc deprecation note in code, an internal Slack heads-up, and a public-facing API consumer changelog.
How to communicate version to Gemini
Gemini accepts a fresh schema per request, so versioning is a client-side responsibility. I instrument every call with the schema version so the migration can be observed in production.
Knowing which version is producing what share of traffic lets you visualize migration progress. My team puts the V1 / V2 ratio on a Grafana panel and reviews it weekly. When the ratio plateaus unexpectedly, that is the first signal of a code path still creating V1 records.
Phased upgrade of legacy data
Past data should be converted in stages, never in one go. The five-step pattern I use:
Filter records where schemaVersion = 1
Run a small batch (100 records), measure error rate
If errors are below 1%, ramp the batch size up
Failed records go to a separate queue; fix the converter and replay
24 hours after rewriting to V2, re-verify the old field's consistency
Start at 100, watch error rate and runtime, then ramp 1,000 / 5,000 / 10,000. Never discard failed records — push them to a side queue so you can diagnose later why they did not convert. Some of the most useful schema observations come out of this queue.
A pitfall: Gemini "remembers" old enums
A subtler issue I noticed in long-running services: when you narrow an enum in V2 (say, splitting tech into tech-news and tech-tutorial), the model occasionally returns a mix of old and new values, especially if the prompt history contains the old names. Old categories quietly slip back in.
The fix that worked: never narrow an enum in place. Always widen — keep all old values, add new ones — and migrate downstream consumers to ignore the old ones explicitly. Widening is safe; narrowing fights the model's prior. Treating enums as append-only datasets removed an entire class of mysterious validation failures.
What to do this week
If you have no schema change in mind right now, the single highest-leverage habit to adopt is adding schemaVersion to every stored record. That alone makes future-you ten times more efficient when the inevitable change request arrives.
interface Stored { schemaVersion: number; // ... other fields}
Designing schemas to grow, not to be permanent, is the quiet investment that turns a Gemini-powered feature from "throwaway prototype" into "system that survives." It is not glamorous. It is the lever that separates code I will be glad I wrote in two years from code I will be cleaning up in two years.
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.