Skip to content
Blog

Structured Outputs: JSON Schema Validation for LLMs

Ask an LLM for JSON and you will get broken JSON. Learn to constrain generation with Gemini response_schema and Pydantic, then verify the output as an explicit, catchable step.

Published on August 3, 2026

AI Assistant

Structured outputs are the first line of verification: instead of hoping the model returns valid JSON, you constrain it and validate it.

Ask an LLM for JSON and you will, sooner or later, get Markdown-fenced JSON, a trailing comma, a key renamed, or a string where a number belongs. In a production pipeline every one of those failures is an uncaught error — “the failure cost: what happens when the AI is wrong and no one catches it.”

Structured outputs solve this at the source: the model is constrained to emit exactly the schema you declare, so parsing becomes deterministic. Validation then becomes a separate, explicit step — the core principle that an LLM is a resource in a value chain, not magic.

Prerequisites

  • Python 3.10+
  • A Gemini API key (GEMINI_API_KEY)
  • google-genai and pydantic installed

Constrained generation with Gemini

Gemini supports structured outputs via response_mime_type="application/json" together with a response_schema:

from typing import Literal
from google import genai
from google.genai import types

client = genai.Client()

response = client.models.generate_content(
    model="gemini-2.5-pro",
    contents="List the key metrics for the system health report.",
    config=types.GenerateContentConfig(
        response_mime_type="application/json",
        response_schema={
            "type": "object",
            "properties": {
                "status": {"type": "string", "enum": ["ok", "warning", "critical"]},
                "latency_p50_ms": {"type": "number"},
                "error_rate_percent": {"type": "number"},
            },
            "required": ["status", "latency_p50_ms", "error_rate_percent"],
        },
    ),
)

print(response.text)

Because the schema is declared, Gemini responds with exactly status, latency_p50_ms, and error_rate_percent, in the right types, so you can parse with json.loads safely.

Defining the schema with Pydantic

For anything nontrivial, define the schema in Pydantic and convert it to a Gemini schema:

from typing import Literal
from pydantic import BaseModel, Field
from google.genai import types

class Incident(BaseModel):
    severity: Literal["low", "med", "high", "critical"]
    service: str = Field(description="Name of the affected service")
    summary: str = Field(description="One-sentence summary")
    related_ids: list[int] = []

config = types.GenerateContentConfig(
    response_mime_type="application/json",
    response_schema=Incident,
)

When the response returns, parse it straight into an Incident object and benefit from Pydantic’s validation and type coercion.

Recovering a schema from samples with genson

You don’t always have a schema in mind. When you want to constrain output to match the structure recovered from free-form samples, genson recovers it offline:

from genson import SchemaBuilder

examples = [
    {"name": "api", "hitsPerSec": 120},
    {"name": "db", "hitsPerSec": 300},
]

builder = SchemaBuilder()
for ex in examples:
    builder.add_object(ex)
schema = builder.to_schema()          # a JSON Schema over the observed samples

Guarding the output (the verification step)

Structured output + a final validation is the “explicit verification step” to design in:

import json
from pydantic import ValidationError

def parse_incident(text: str) -> Incident | None:
    try:
        return Incident.model_validate(json.loads(text))
    except (json.JSONDecodeError, ValidationError) as e:
        raise VerificationError("structured output failed validation") from e

The decision to re-prompt, route to human review, or drop the record belongs to your governance policy — but it is now an explicit, catchable step.

Putting It All Together

A small function that returns a constrained, validated health report:

from typing import Literal
from pydantic import BaseModel
from google import genai
from google.genai import types

client = genai.Client()

class HealthReport(BaseModel):
    status: Literal["ok", "warning", "critical"]
    latency_p50_ms: float
    error_rate_percent: float

def report(prompt: str) -> HealthReport:
    resp = client.models.generate_content(
        model="gemini-2.5-pro",
        contents=prompt,
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
            response_schema=HealthReport,
        ),
    )
    health = HealthReport.model_validate(json.loads(resp.text))
    return health

Conclusion & Next Steps

Structured outputs remove the most common class of brittle LLM bugs. Next: add retry-on-invalid where a validation error is fed back as a corrective prompt, and wire schema checks into your downstream contract tests so a change in shape fails loudly rather than silently. — verification must be built in, not hoped for.

References / Sources