Tutorial

How does the Gemini API output JSON? A complete Structured Output and JSON Schema tutorial

Stop asking the model to “please return JSON”. Pin the shape with Structured Output, constrain fields with JSON Schema, and let downstream code parse with confidence.

You ask Gemini for JSON and get a preamble, a missing comma, or friendlier field names. Telling the prompt to “output JSON only” lowers the odds; it is not a contract. Structured Output moves the contract into decoding: declare a MIME type, attach a schema, and the model emits tokens in that shape. After this guide you can choose JSON mode vs Schema mode, write a working request, and still validate locally.

Why structured output

Once downstream calls JSON.parse, failure is not a copy issue — the whole pipeline stops. Classifiers need fixed enums, extractors need stable keys, tool calls need parameter objects. Free-form answers are expensive: one bad JSON means retries, logs, or another click from the user.

A quieter failure is “it parses, but the shape is wrong”. You expected items to be an array and got an object; you expected score to be a number and got "0.9". The code reads undefined, and the bug explodes much later. Structured Output fixes shape, not truth: you get legal, schema-shaped JSON, not a guaranteed-correct category. Production still needs business checks — parsing just gets quieter.

See the official capability notes in the Gemini Structured output docs. Google also announced broader JSON Schema keywords and property ordering; read the Structured Outputs update. Before you write a schema, skim Understanding JSON Schema so you do not confuse “keywords the spec allows” with “keywords this model actually enforces”.

JSON mode vs Schema mode

Think of two switches. The first only promises “this string will parse as JSON”. The second promises “this JSON matches the schema you declared”. If your code reads named fields, use the second. Use the first only for exploratory extraction where the model also invents keys.

Level What you configure What you actually get
JSON mode Only responseMimeType: application/json Usually valid JSON; names and nesting are still the model’s choice
Schema mode MIME type + responseSchemaorresponseJsonSchema Shape, types, required fields, and enums follow the schema

With JSON mode alone, docs still treat it as a strong hint with a small risk of malformed output. To get close to “always parse as an object”, send a schema too. The schema counts toward input tokens, so do not paste the same description into the prompt: duplication hurts quality and quota.

responseSchema or responseJsonSchema

responseSchema uses an OpenAPI 3.0–style schema subset. REST type names are often uppercase, such as OBJECT, STRING. It fits flat objects, enum classification, and pinning key order with propertyOrdering. It does not understand $ref / $defs, so recursive trees and shared defs must be inlined and soon explode in size.

responseJsonSchema targets Gemini 2.5 and newer and speaks closer-to-standard JSON Schema, covering anyOf, $ref, minimum / maximum, additionalProperties, type: null, prefixItems, and more. Generating the schema from Pydantic or Zod reduces friction. Newer models keep key order as declared, which helps log diffs and golden tests.

Three rules of thumb. Flat classify/extract: either field works. Recursion, shared defs, or unions: prefer responseJsonSchema. If you must pin field order, confirm the endpoint still honors propertyOrdering; do not assume every API surface behaves the same.

How to write the schema

Describe the object you will actually read, not a complete world model. Every extra optional field is another chance to fill garbage. Put required names in required, enums in enum, numeric bounds in minimum / maximum. Adding description on properties is often stabler than explaining them again in the prompt, because the constraint rides with decoding.

The schema below models ticket classification: category is one of three values, priority is an integer, summary is a string. That is what Schema mode should own — shape, not whether the ticket is truly urgent.

{
  "type": "object",
  "properties": {
    "category": {
      "type": "string",
      "enum": ["billing", "bug", "feature"],
      "description": "Ticket category"
    },
    "priority": {
      "type": "integer",
      "minimum": 1,
      "maximum": 5
    },
    "summary": {
      "type": "string"
    }
  },
  "required": ["category", "priority", "summary"],
  "additionalProperties": false
}

Arrays use items for elements. For tuple-like fixed lists, look at prefixItems on the JSON Schema path. Do not treat “not in required” as nullable — explicitly allow null, or the model may omit the key while your code still assumes obj.field always exists.

Inline nested objects. Reach for $defs + $ref only when the same structure appears a third time or a tree node references itself. Early abstraction makes rejections harder to read: you debug an expanded blob when the server refuses the schema.

Python and JavaScript in practice

The snippets use common official SDK shapes. Replace the model id with whatever 2.5 / newer SKU your project actually has — do not treat the sample name as a frozen production pin.

Python: MIME type + JSON Schema

from google import genai

client = genai.Client()
schema = {
    "type": "object",
    "properties": {
        "category": {"type": "string", "enum": ["billing", "bug", "feature"]},
        "priority": {"type": "integer"},
        "summary": {"type": "string"},
    },
    "required": ["category", "priority", "summary"],
}

response = client.models.generate_content(
    model="gemini-2.5-flash",
    contents="Classify this ticket: invoice PDF cannot be downloaded.",
    config={
        "response_mime_type": "application/json",
        "response_json_schema": schema,
    },
)
print(response.text)

If the team already models with Pydantic, pass Model.model_json_schema() to response_json_schema, then model_validate_json(response.text) for a second local check. Layer one is the API shape; layer two is your type system rejecting values that look legal but are nonsense, like priority 99.

JavaScript: generationConfig

const response = await ai.models.generateContent({
  model: "gemini-2.5-flash",
  contents: "Classify this ticket: invoice PDF cannot be downloaded.",
  config: {
    responseMimeType: "application/json",
    responseJsonSchema: {
      type: "object",
      properties: {
        category: { type: "string", enum: ["billing", "bug", "feature"] },
        priority: { type: "integer" },
        summary: { type: "string" },
      },
      required: ["category", "priority", "summary"],
    },
  },
});
const data = JSON.parse(response.text);

REST calls put the same fields on generationConfig. OpenAPI-style responseSchema still uses uppercase types on some endpoints — do not mix that with JSON Schema’s lowercase object. Parse immediately with JSON.parse; do not scrape a fenced code block with regex. You declared JSON MIME, so treat the whole body as JSON.

Common pitfalls

  • Writing the structure twice — once in the prompt and once in the schema — makes the model wobble between the two descriptions.
  • Oversized schemas: deep nesting, deep $ref, or wide anyOf may be rejected or weakly enforced. Ship a tiny object first, then grow it.
  • Do not outsource truth to the schema. Enums limit the set; they do not stop a wrong pick. Spot-check or add rules on critical paths.
  • Local types drift from the request schema. You change Pydantic/Zod and forget the payload schema; production quietly grows extra keys or drops old ones.
  • Do not test only the happy path. Add empty arrays, nullable fields, long strings, and illegal enums (which should be blocked).

One more engineering issue: logs should not store only response.text. Record model id, schema hash, and prompt version. Structured-output breakage is usually “defaults changed” or “a tiny schema edit got rejected”. Without those three you will swear it worked yesterday.

Ship it: validate, compare, retry

Treat the API body as untrusted bytes. Parse, validate against the same schema, then map to internal types. On failure, log the raw text (redact secrets) and choose retry vs degrade. Do not retry with a totally different schema or you cannot tell model noise from a moving contract.

While debugging, paste samples into the on-site tools. JSON formatter to see nesting, JSON validator to catch syntax, then drop the contract into JSON Schema to see if the instance passes. When fields appear or vanish, JSON Diff two responses instead of scanning logs by eye.

When you design an extraction pipeline, hand-write one “ideal output”, infer types with the Schema tool, and paste that schema back into the Gemini request. The contract then lives in one place: a document you can test, not a verbal agreement in chat history.

FAQ

Is application/json enough by itself?

Fine for exploration. As soon as code reads fixed fields, send a schema too. Otherwise you get JSON-shaped prose, not an API.

Can Structured Output replace function calling?

No. Function calling lets the model pick a tool and fill arguments. Structured Output constrains this answer’s shape. Need to run code or hit an external API? Use tools. Need one typed blob? Use Structured Output and skip a round trip.

Why was my schema rejected?

Usually unsupported keywords on that endpoint, recursion that is too deep, or mixing responseSchema and responseJsonSchema dialects. Shrink to one object with three fields, prove it works, then add.

Is the output always correct?

No. Shape can be valid while facts are wrong. Money, emails, and ticket categories still need rules or human sampling.

Summary and next steps

Reliable JSON does not come from a longer “please output JSON only”. It comes from a MIME type plus a schema. For flat jobs either responseSchemaorresponseJsonSchema is fine; for $ref, unions, or schemas generated from Pydantic/Zod, use the latter. Still validate locally, and turn failures into regression tests with format, Schema, and Diff.

Next step: take the most fragile endpoint you have and make it “one schema, one request, one local validate”. Quiet that path first, then copy the pattern.