How I use Gemini Structured Outputs to turn messy data into typed JSON

September 4th, 202619 mins read

How I use Gemini Structured Outputs to turn messy data into typed JSON

One of the easiest ways to make an AI extraction demo look finished is to add “return JSON” to the prompt.

The first response often looks perfect, but when you run the same extraction against another document, weight may become product_weight, a missing field may disappear completely, or a value that came back as 1800 yesterday may return as "1.8 kg" today.

A person can usually make sense of those differences, but your application code needs something more predictable, which is where Gemini's Structured Outputs become useful.

Instead of describing the response shape entirely in the prompt, I can define it with JSON Schema, including the properties, types, required fields, allowed values, and other constraints my application expects.

Google positions Structured Outputs specifically for cases such as data extraction, classification, and agent workflows. The JavaScript SDK can also work with Zod, while Python supports Pydantic, which means the JSON generated by the model can move into the same validation tools I already use.

The pattern looks roughly like this:

A prompt and JSON Schema being sent to Gemini together, returning validated JSON
The schema travels alongside the prompt instead of living only inside it.

The schema does not make the model automatically correct, which is an important distinction I will come back to. What it gives me is a much cleaner boundary between the model and the rest of my application.

“Return JSON” leaves more decisions to the model than it seems

Imagine I have a product specification PDF containing information scattered between introductory text, a specifications table, and a few notes further down the document.

It might contain something like:

A product specification document with details scattered across text, a table, and notes
The values I need are spread across the document rather than sitting in one place.

I could ask Gemini:

Extract the product name, weight, network standards,
number of Ethernet ports, operating temperature and warranty. Return JSON.

And it may return:

{
  "product": "AeroHub X2",
  "weight": "1.8 kg",
  "wifi": ["Wi-Fi 6", "802.11ax"],
  "ethernet_ports": 4,
  "temperature": "0°C to 40°C",
  "warranty": "24 months"
}

There is nothing obviously wrong with that response, but the problem shows up when I think about what the application needs to do next.

Does it expect product or product_name? Should weight always be a string with a unit, or a number in a fixed unit? Is warranty stored as "24 months", 24, or 2? Should temperature stay as one string or become separate minimum and maximum values, and what happens when a field like weight is missing entirely?

By asking the model to simply "return JSON," I am really asking it to extract the information and decide what shape my application data should take — that's two jobs at once. I would rather keep control of that second decision.

Define what the application expects first

For this example, I could define a schema like this:

const productSchema = {
  type: "object",
  additionalProperties: false,
  properties: {
    product_name: {
      type: "string",
      description: "Full product name exactly as stated in the source."
    },
    wireless_standards: {
      type: "array",
      items: {
        type: "string"
      },
      description: "Wireless standards explicitly supported by the product."
    },
    ethernet_ports: {
      type: ["integer", "null"],
      minimum: 0,
      description:
        "Number of physical Ethernet ports. Return null if not stated."
    },
    weight_grams: {
      type: ["number", "null"],
      minimum: 0,
      description:
        "Product weight converted to grams. Return null if no weight is stated."
    },
    operating_temperature: {
      type: "object",
      additionalProperties: false,
      properties: {
        min_celsius: {
          type: ["number", "null"]
        },
        max_celsius: {
          type: ["number", "null"]
        }
      },
      required: ["min_celsius", "max_celsius"]
    },
    warranty_months: {
      type: ["integer", "null"],
      minimum: 0,
      description:
        "Warranty period expressed in months. Return null if not stated."
    }
  },
  required: [
    "product_name",
    "wireless_standards",
    "ethernet_ports",
    "weight_grams",
    "operating_temperature",
    "warranty_months"
  ]
};

Now the model is working within a structure I have already defined. Weight always comes back in grams, warranty in months, temperature follows the same shape every time, and fields I care about remain present even when the source does not contain a value.

Gemini Structured Outputs supports objects, arrays, strings, numbers, integers, booleans, and null, along with constraints such as required, additionalProperties, enum, minimum, maximum, minItems, and maxItems. Google describes this as support for a subset of JSON Schema rather than the full specification.

I find it useful to think about the difference this way:

Prompt
What should the model do?

Schema
What is my application willing to accept?

I still need both, but they solve different problems.

Sending a PDF to Gemini and getting structured output back

Gemini 3.8 Flash currently supports PDF input and Structured Outputs, so I can give it the source document and the schema in the same interaction.

Using the current Google GenAI JavaScript SDK, a basic version looks like this:

import { GoogleGenAI } from "@google/genai";
import * as z from "zod";

const ai = new GoogleGenAI({});

const productJsonSchema = {
  type: "object",
  additionalProperties: false,
  properties: {
    product_name: {
      type: "string",
      description: "Full product name exactly as stated in the source."
    },
    wireless_standards: {
      type: "array",
      items: {
        type: "string"
      },
      description: "Wireless standards explicitly supported by the product."
    },
    ethernet_ports: {
      type: ["integer", "null"],
      minimum: 0,
      description:
        "Number of physical Ethernet ports. Return null if not stated."
    },
    weight_grams: {
      type: ["number", "null"],
      minimum: 0,
      description:
        "Product weight converted to grams. Return null if not stated."
    },
    operating_temperature: {
      type: "object",
      additionalProperties: false,
      properties: {
        min_celsius: {
          type: ["number", "null"]
        },
        max_celsius: {
          type: ["number", "null"]
        }
      },
      required: ["min_celsius", "max_celsius"]
    },
    warranty_months: {
      type: ["integer", "null"],
      minimum: 0,
      description:
        "Warranty period expressed in months. Return null if not stated."
    }
  },
  required: [
    "product_name",
    "wireless_standards",
    "ethernet_ports",
    "weight_grams",
    "operating_temperature",
    "warranty_months"
  ]
};

const productSchema = z.fromJSONSchema(productJsonSchema);

const file = await ai.files.upload({
  file: "./product-specification.pdf",
  config: {
    mime_type: "application/pdf"
  }
});

const interaction = await ai.interactions.create({
  model: "gemini-3.8-flash",
  input: [
    {
      type: "text",
      text: `
        Extract the requested product specifications from this document.

        Use only information available in the document.
        Convert weight to grams and warranty duration to months.
        Do not infer specifications that are not stated.
        Return null when a requested value is unavailable.
      `
    },
    {
      type: "document",
      uri: file.uri,
      mime_type: file.mimeType
    }
  ],
  response_format: {
    type: "text",
    mime_type: "application/json",
    schema: productJsonSchema
  }
});

const product = productSchema.parse(
  JSON.parse(interaction.output_text)
);

console.log(product);

Google's current Interactions API puts the JSON configuration inside response_format, with application/json as the MIME type and the JSON Schema passed through schema. The company changed this API shape in 2026, so older Gemini examples using fields such as response_mime_type may look different.

The Files API is useful when I want to upload a document once and reference it from the interaction. For smaller or temporary files, the current API can also accept a PDF directly as base64 data rather than uploading it first.

For our example source, I would expect something close to:

{
  "product_name": "AeroHub X2",
  "wireless_standards": [
    "Wi-Fi 6",
    "802.11ax"
  ],
  "ethernet_ports": 4,
  "weight_grams": 1800,
  "operating_temperature": {
    "min_celsius": 0,
    "max_celsius": 40
  },
  "warranty_months": 24
}

The response is now much easier for the next part of the application to consume. I am not parsing "1.8 kg" manually or wondering whether temperature changed shape between two extractions.

The schema can explain what each field means

The description fields deserve more attention than they usually get. Suppose I only define:

dimensions: {
  type: "object"
}

That tells Gemini almost nothing about what I mean.

Even this is ambiguous:

width: {
  type: "number"
}

Is the number millimetres, centimetres, or inches? Does it include packaging? Should the model convert whatever unit appears in the source?

I can make that explicit:

width_mm: {
  type: ["number", "null"],
  description:
    "Width of the product itself in millimetres. Do not use package dimensions. Convert other units when necessary."
}

Google recommends using clear descriptions and strong types as part of its Structured Outputs guidance. The schema therefore does more than constrain the shape after generation. Its names, descriptions, enums, and types also give the model information about what I mean by each property.

That makes the schema useful for two related jobs:

control the output shape
          +
remove ambiguity about the fields

This becomes especially important when a source contains several similar values.

A product document might contain device weight and shipping weight, maximum power and typical power, operating temperature and storage temperature, or model number and serial number. All are legitimate values, so a vague property name can make the extraction structurally correct while still selecting the wrong one.

Missing data should have somewhere to go

I also try not to design extraction schemas as if every document will contain every field.

Imagine my schema says:

warranty_months: {
  type: "integer"
}

and the product document never mentions a warranty.

If my application requires that property, I have created a conflict between the schema and the source. The model is expected to produce a number even though there is no number to extract.

I would rather make absence part of the structure:

warranty_months: {
  type: ["integer", "null"],
  description:
    "Warranty period expressed in months. Return null if the source does not state a warranty."
}

I can still include warranty_months in required.

That means this is a valid result:

{
  "warranty_months": null
}

There is an important distinction here. A required property means I expect the response to tell me something about that field. It does not have to mean that the underlying document must contain a value.

Gemini's supported JSON Schema subset explicitly allows null by including it in the property's type array.

For extraction systems, I prefer that explicit absence over simply dropping the property. My downstream code can now distinguish between “the extractor did not return this field” and “the extractor checked this field and did not find a value.”

Enums can normalize data before it reaches the application

Schemas become even more useful when the source can describe the same concept in several ways.

Suppose I am extracting the installation method for different products.

Without any constraint:

installation_type: {
  type: "string"
}

I might get:

Wall Mount
wall-mounted
Wall mounting
Mounted on wall
wall

Those responses are understandable, but I probably do not want all five versions stored separately.

If the application only supports a fixed set of categories, I can move that decision into the schema:

installation_type: {
  type: ["string", "null"],
  enum: [
    "wall",
    "desk",
    "rack",
    "ceiling",
    "other",
    null
  ],
  description:
    "Primary installation method. Use other when the source states a method outside the listed categories."
}

Now Gemini is doing part of the normalization while it extracts the data.

Structured Outputs supports enums for string and numeric values, as well as numerical bounds for number and integer properties.

I would still be careful about the categories I choose. A schema cannot rescue a bad taxonomy, but once I know the vocabulary my application expects, an enum is much cleaner than cleaning up dozens of model-generated variations afterward.

Nested objects are useful when a value needs context

Sometimes a single primitive value throws away information I care about.

Consider:

{
  "weight_grams": 1800
}

That may be enough for a catalogue, but if I am building an extraction system where important values need to be reviewed or verified later, I would usually want to keep a little more context around them.

{
  "weight": {
    "value_grams": 1800,
    "source_page": 6,
    "source_text": "Weight 1.8 kg"
  }
}

The schema could describe that as:

weight: {
  type: "object",
  additionalProperties: false,
  properties: {
    value_grams: {
      type: ["number", "null"]
    },
    source_page: {
      type: ["integer", "null"],
      description:
        "Page number where the weight was found."
    },
    source_text: {
      type: ["string", "null"],
      description:
        "Short supporting text from the source document."
    }
  },
  required: [
    "value_grams",
    "source_page",
    "source_text"
  ]
}

This still does not prove that the extracted value is correct, but it gives me something concrete to inspect. An internal review screen could show the extracted value alongside the source text, suspicious results could be routed for manual review, and repeated extraction attempts could be compared to see whether they point to the same evidence.

At that point, the schema is doing more than controlling the shape of the response. It is also helping define how the extraction workflow should be reviewed and trusted.

Gemini can work with the document instead of only flattened text

The combination becomes particularly useful with PDFs because Gemini's document understanding is multimodal.

Google says Gemini can use both the visual and textual contents of PDFs, including text, images, diagrams, charts, tables, and document layout. Its documentation currently supports PDF processing for documents up to 1,000 pages, subject to the relevant file limits.

That matters when the source looks like this:

A specification table where values only make sense in relation to their row and column headers
Spatial layout carries meaning here that flattened text can lose.

Flattening that table into text can preserve every word and number while losing enough spatial context to make the relationship between them harder to recover.

The same issue appears in manuals, catalogues, specification sheets, forms, reports, and scanned documents.

A traditional pipeline might look something like:

A traditional extraction pipeline with OCR, layout parsing, and hand-written rules before the data reaches the application
A document-specific parsing pipeline, built and maintained by hand.

There are cases where Gemini lets me test a shorter route:

A shorter pipeline where Gemini and a JSON Schema replace most of the hand-written parsing steps
Fewer moving parts, at the cost of trading deterministic parsing for a model.

I would not assume the second pipeline should replace the first one everywhere. If a document format is stable and a deterministic parser already extracts it perfectly, introducing a language model may add cost and uncertainty without solving a real problem.

The interesting cases are the messy ones, where layouts change, fields move around, the same information appears in different formats, and maintaining document-specific parsing rules starts becoming the harder part of the system.

Schema-valid JSON can still contain the wrong answer

This is the most important limitation to understand.

Imagine Gemini returns:

{
  "ethernet_ports": 8
}

My schema says ethernet_ports must be a non-negative integer.

Eight satisfies that schema perfectly, but if the document actually says four, the extraction is still wrong.

Structured Outputs gives me structural reliability, not factual certainty.

Google makes this explicit in its best-practice guidance. The model can produce syntactically correct JSON that conforms to the schema while still getting the semantic value wrong, so Google recommends validating generated values in application code and handling schema-compliant but incorrect results.

I think about those as two separate checks:

Two checks on model output: does it match the schema, and is the value actually correct
Schema validation and correctness are two different questions.

The second check depends entirely on the application.

If ethernet_ports cannot exceed 64 for the product class I am importing, I can validate that. If an operating temperature has a minimum greater than its maximum, I can reject it. If a field belongs to a fixed list of protocols, I can compare the extracted values with that list.

For important data, I might also verify the supporting evidence rather than accepting the model's first answer automatically.

Clean JSON can make an incorrect answer look more trustworthy, so I do not treat schema compliance as proof that extraction succeeded.

I still validate the result after Gemini returns it

This is why I like pairing the JSON Schema with something such as Zod.

The model returns the JSON:

const raw = JSON.parse(interaction.output_text);

Then the application validates it:

const product = productSchema.parse(raw);

At that point I know the structure conforms to the type I expect.

I can then add application-specific checks:

if (
  product.operating_temperature.min_celsius !== null &&
  product.operating_temperature.max_celsius !== null &&
  product.operating_temperature.min_celsius >
    product.operating_temperature.max_celsius
) {
  throw new Error("Invalid operating temperature range");
}

if (
  product.ethernet_ports !== null &&
  product.ethernet_ports > 64
) {
  throw new Error("Unexpected Ethernet port count");
}

The exact rules will be different for every extraction system, but the principle stays the same.

I let Gemini handle the messy interpretation problem, use the schema to control what crosses the model boundary, and let normal application code enforce rules I can check deterministically.

The prompt still has work to do

Once a schema exists, it can be tempting to put every extraction instruction inside the property descriptions and barely write a prompt.

I do not think that is a good separation.

The schema tells Gemini what my result should look like. The prompt can still explain how I want it to interpret the source.

For example:

Extract the requested product specifications.

Use only information stated in the document.

When specifications are listed for multiple models,
extract values only for AeroHub X2.

Convert weight to grams.

Do not use shipping weight when product weight is available.

Do not infer missing specifications from similar models.

Return null when a requested value is not stated.

Some of those instructions cannot be represented cleanly as JSON Schema constraints.

A schema can say that weight_grams is a number. It cannot completely express that I want the device weight rather than the shipping weight, or that Gemini should never borrow a missing specification from another model in the same comparison table.

Google's own Structured Outputs guidance still recommends clear prompting alongside strong typing and good property descriptions.

The prompt explains the extraction task. The schema defines the output interface.

I use them together rather than trying to make one do both jobs.

Keep the schema strict where it matters

Once I started treating model output this way, it became tempting to keep adding more structure with nested objects, extra metadata, enums, and increasingly specific constraints.

But there is a point where that stops making the extraction better.

Google supports only a subset of JSON Schema for Structured Outputs and notes that very large or deeply nested schemas can be rejected. Even before I get anywhere near those limits, I still think it is worth asking whether each extra layer of structure is actually useful to the application.

If all I need from a small document is:

{
  "product_name": "AeroHub X2",
  "model_number": "AH-X2",
  "weight_grams": 1800
}

I do not need to turn each field into three nested objects.

I use additionalProperties: false when unexpected fields would create problems downstream. I use enums when my application already has a controlled vocabulary, nullable fields when the source may legitimately omit information, and descriptions where similar values are easy to confuse.

The goal is a predictable boundary between Gemini and the application, not the most complicated schema I can produce.

Structured Outputs does not solve the whole extraction pipeline

Getting JSON from a language model was never particularly difficult. Getting output with a stable enough structure to become part of an application is the more useful problem.

Structured Outputs does not solve the whole extraction pipeline, and it does not guarantee that the model read every value correctly. What it does is let me decide exactly what kind of data is allowed to cross from the model into the rest of my software.

When I am turning messy documents into application data, that is a much better place to start.


Joel Olawanle

Joel Olawanle

Software Engineer, Technical Writer & Editor. Co-founder of Spidra and NGN Market.

Follow on Twitter