Your responseSchema looks correct on paper, yet Gemini rejects it
The first time I shipped structured output (responseMimeType: "application/json" + responseSchema) into production, I was feeding monthly AdMob reports into Gemini 2.5 Flash so it could write per-ad-unit eCPM commentary as JSON. Local tests worked beautifully. The moment I rolled it out across all my apps, this error started showing up:
400 INVALID_ARGUMENT
{
"error": {
"code": 400,
"message": "Invalid JSON payload received. Unknown name \"additionalProperties\" at 'generation_config.response_schema'",
"status": "INVALID_ARGUMENT"
}
}
A few days later, requests started succeeding (HTTP 200), but candidates[0].content.parts[0].text came back empty, with finish_reason: "OTHER". Then Pydantic blew up at model_validate_json() with ValidationError: 1 validation error for AdReport units Field required. Structured output failures actually live on three different layers — server-side rejection, silent empty responses, and client-side parsing — and each one needs a different fix. Let me walk through them in order, with the patterns I now use across my own production pipeline.
Layer 1 — server-side rejection of your responseSchema
Gemini's responseSchema is a subset of OpenAPI 3.0 Schema, not full JSON Schema. If you copy-paste a JSON Schema you've used elsewhere, expect a 400. The most common offenders:
- Including
additionalProperties(unsupported) - Deeply nested
oneOf/anyOf $refto another schemapattern(regex-based validation)- Array-typed fields like
type: ["string", "null"] - OpenAPI-style
nullable: truewhen the SDK expects JSON-Schema-stylenullin the type
My first stumble was additionalProperties: false, which is a JSON Schema convention for "no extra keys allowed". Gemini simply doesn't know that key, so it returns 400. The least error-prone fix in Python is to define your schema as a Pydantic model and hand the model directly to the SDK — let it convert.
from pydantic import BaseModel
from google import genai
class AdUnit(BaseModel):
name: str
ecpm: float
impressions: int
comment: str
class AdReport(BaseModel):
period: str
units: list[AdUnit]
summary: str
client = genai.Client()
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Summarize last month's AdMob report",
config={
"response_mime_type": "application/json",
"response_schema": AdReport, # pass the Pydantic class directly
},
)
# .parsed gives you a typed object
report: AdReport = response.parsedThe SDK converts the Pydantic model to OpenAPI 3.0 internally. Since I switched my production code to this pattern, 400-class errors went to zero. Hand-written schema JSON quietly drifts as the API evolves; deriving the schema from typed Python (Pydantic or typing.TypedDict) keeps you safe across SDK versions.
Layer 2 — request accepted but the response is empty
You get a 200 back, but text is empty and finish_reason is either "OTHER" or "MAX_TOKENS". This bucket has three distinct causes:
- Schema too strict for the model to satisfy. Too many
requiredfields, largeminItemson arrays, orenumvalues that don't match real-world data. - Output exceeded
maxOutputTokens. JSON is verbose, so the default 1024 you'd use for text generation gets cut off mid-object. - Safety filter triggered. Especially in non-English content analysis —
finish_reason: "SAFETY"returns an empty body.
I hit the first one hardest. My AdReport had minItems: 10 on units, but in a slow month I only had 7 active ad units, and the response went silent every time. The rule I now follow: schemas should encode the minimum data contract, not the business wishlist. Anything aspirational belongs in post-validation, not in the schema.
maxOutputTokens matters more for JSON than for text. Just the braces, quotes, colons, and commas burn through your budget. Set it generously.
import { GoogleGenerativeAI, SchemaType } from "@google/generative-ai";
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: SchemaType.OBJECT,
properties: {
period: { type: SchemaType.STRING },
units: {
type: SchemaType.ARRAY,
items: {
type: SchemaType.OBJECT,
properties: {
name: { type: SchemaType.STRING },
ecpm: { type: SchemaType.NUMBER },
comment: { type: SchemaType.STRING },
},
required: ["name", "ecpm", "comment"],
},
},
},
required: ["period", "units"],
},
maxOutputTokens: 8192, // JSON is verbose — go larger than your text-generation default
temperature: 0.2, // low temperature stabilizes structured output
},
});
const result = await model.generateContent("Convert AdMob monthly report to JSON");
const json = result.response.text();
const report = JSON.parse(json);If you suspect safety filtering, log result.response.promptFeedback and candidates[0].safetyRatings to confirm. Always log finish_reason in production — it's the single field that lets you triage unreproducible bugs after the fact.
Layer 3 — client-side JSON.parse / Pydantic validation failures
Even when the server returns clean structured JSON, your client can still fall over. The cases I see most often:
- Trailing commentary. If your prompt says "return JSON" but also "explain your reasoning", Gemini sometimes wraps the JSON in a code fence or adds prose around it.
responseMimeType: "application/json"usually prevents this, but hightemperaturecan still cause bleed-through. - Numbers returned as strings. You declared
type: NUMBERbut got back"3.14". Pydantic v2 in strict mode rejects this. UseField(..., strict=False)orAnnotated[float, BeforeValidator(float)]to coerce. nullvs. missing. Optional fields come back asnullone day and are omitted the next. Always declare client-side asOptional[str] = None; never require their presence.
In production I add a small defensive layer right before JSON.parse: strip code fences, slice from the first { to the last }, then parse. That one block dropped my client-side error rate by about an order of magnitude.
function extractJson(raw: string): string {
// Strip code fences
let s = raw.replace(/```json\s*|\s*```/g, "").trim();
// Slice between the first { and last } (defend against prose bleed-through)
const first = s.indexOf("{");
const last = s.lastIndexOf("}");
if (first !== -1 && last !== -1) {
s = s.slice(first, last + 1);
}
return s;
}
try {
const report = JSON.parse(extractJson(result.response.text()));
return report;
} catch (e) {
console.error("Structured output parse failed", {
finishReason: result.response.candidates?.[0]?.finishReason,
raw: result.response.text().slice(0, 500),
});
throw e;
}With Pydantic, give every field a default and a lenient validator. The day Gemini's behavior shifts a notch, your production code stays up.
A defensive implementation pattern
Pulling all of the above into the pattern I run across my indie apps — which collectively have shipped 50 million-plus downloads since 2014, with AdMob as the primary revenue source — here's the playbook.
Define your schema as the minimum viable contract. Mark fields required only when missing them genuinely breaks downstream code. For enums, add a catch-all value like "other" so future categories don't blow up your pipeline. Never write additionalProperties or other unsupported keys. Generate the schema from typed Python whenever possible — hand-written JSON Schema is a maintenance trap.
On the request side, run temperature: 0.0–0.3 and maxOutputTokens at roughly 1.5× your worst-case expected length. On the response side, always log finish_reason. Anything other than STOP becomes a retry candidate. Use exponential backoff up to three attempts, and as a final fallback, retry with a looser schema (drop optional fields, drop array constraints) so you at least get partial data.
On the client, build a four-stage defense: code-fence strip → JSON slice → lenient Pydantic validation → business-logic re-validation. Log every stage so you can mine failure patterns later. I review those logs weekly, and any new failure class earns one more line of defense.
Once you get past the first wall, structured output is genuinely transformative. In my own setup it powers AdMob report summarization, App Store Connect review classification, and Firebase Crashlytics log triage — the whole observability layer of an indie developer's app business. When validation errors hit, slow down and ask which layer is failing: server rejection, model silence, or client parsing. That single question shortens debugging time more than any other heuristic I know.
If you're working through the same problem, I hope this saves you a few late nights.