AI Engineering: Build AI-Powered Apps
Hands-onIntermediate 35 min· Working with LLM APIs · 4 of 19

Structured output with JSON schemas

Make the model return data your code can trust. Define a schema with Pydantic, request JSON output from the Gemini API, validate the values and handle missing information, using a coffee-order parser as the example.

What you will build: a function that turns a messy customer message such as "2 cappuccinos and a croissant for 6:30pm, name is Ravi" into a clean Python object your code can trust, using a JSON schema and Pydantic. You will also validate the result and handle the cases where the model cannot fill it in.

Prerequisites: *Your first LLM API call (free Gemini API)*, and basic Python classes. For how to ask for JSON in a plain chat prompt, see *Getting structured output: tables, JSON and checklists* in Prompt Engineering; this lesson is the programmer's version, where the API enforces the format.

Why free text is not enough

Programs need data, not paragraphs. If you ask a model to "reply in JSON" it will usually comply, but sometimes it wraps the JSON in markdown fences, adds a friendly sentence, uses a different key name, or returns a number as a string. Any of these crashes your json.loads at 2 am.

Structured output fixes this at the API level. You give the API a schema (the shape you want), and the model is constrained to produce JSON that matches it. You still must validate the *values*, because a correctly shaped answer can be wrong.

Three ways to get JSON

ApproachReliabilityNotes
"Reply in JSON" in the promptMediumNeeds cleaning and retries
JSON mode only (valid JSON, no schema)BetterValid syntax but keys may vary
JSON mode plus a schemaBestShape is enforced; values still need checking

Step 1: Describe the shape with Pydantic

Pydantic lets you describe data as a Python class. The same class becomes the schema you send to the API and the validator you use on the way back.

Terminal
pip install -U google-genai pydantic python-dotenv
schema.py
from enum import Enum

from pydantic import BaseModel, Field


class ItemId(str, Enum):
    espresso = "espresso"
    cappuccino = "cappuccino"
    cold_brew = "cold-brew"
    filter_coffee = "filter-coffee"
    masala_chai = "masala-chai"
    croissant = "croissant"
    banana_bread = "banana-bread"
    veg_sandwich = "veg-sandwich"


class OrderLine(BaseModel):
    item_id: ItemId
    qty: int = Field(ge=1, le=20, description="How many of this item")


class ParsedOrder(BaseModel):
    customer_name: str | None = Field(
        default=None, description="Customer's name, or null if not mentioned"
    )
    items: list[OrderLine]
    pickup_time: str | None = Field(
        default=None, description="Pickup time as HH:MM in 24-hour format, or null"
    )
    unclear: list[str] = Field(
        default_factory=list,
        description="Anything the customer asked for that is not on the menu or is ambiguous",
    )

The Enum limits item_id to real menu IDs. description text is sent to the model and acts as an instruction for each field.

Enums and descriptions are your friends

Use enums for any field with a fixed set of values (categories, statuses, menu IDs). It is the single best way to stop the model inventing values. Write description for each field the way you would brief a colleague.

The plan

  1. 1Describe the data you want as a Pydantic class (Step 1).
  2. 2Pass that class as response_schema with response_mime_type="application/json" (Step 2).
  3. 3Read response.parsed to get a typed object.
  4. 4Validate the values with your own checks (Step 3).

Step 2: Ask for output that matches the schema

parse_order.py
from dotenv import load_dotenv
from google import genai
from google.genai import types

from schema import ParsedOrder

load_dotenv()
MODEL = "gemini-flash-latest"  # use a current model ID
client = genai.Client()

MENU_NOTE = (
    "Menu: espresso, cappuccino, cold brew, filter coffee, masala chai, "
    "butter croissant, banana bread, veg grilled sandwich."
)


def parse_order(text: str) -> ParsedOrder:
    response = client.models.generate_content(
        model=MODEL,
        contents=text,
        config=types.GenerateContentConfig(
            system_instruction=(
                "You extract coffee shop orders from customer messages. "
                + MENU_NOTE
                + " Only use items from the menu. If the customer asks for something "
                "else, put it in `unclear`. Never guess a name or time."
            ),
            temperature=0,
            response_mime_type="application/json",
            response_schema=ParsedOrder,
        ),
    )
    # response.parsed is already a ParsedOrder object when a Pydantic class is used
    if response.parsed is None:
        raise ValueError("Model returned no valid JSON: " + str(response.text))
    return response.parsed


if __name__ == "__main__":
    order = parse_order("Hi, 2 cappuccinos and a croissant for 6:30pm, name is Ravi. Also a pizza.")
    print(order.model_dump_json(indent=2))

response_mime_type asks for JSON and response_schema gives the shape. Temperature 0 keeps extraction steady.

Expected output (formatting may differ slightly)
{
  "customer_name": "Ravi",
  "items": [
    {"item_id": "cappuccino", "qty": 2},
    {"item_id": "croissant", "qty": 1}
  ],
  "pickup_time": "18:30",
  "unclear": ["pizza"]
}

Look at what happened: the "pizza" is not on the menu, so it went into unclear, and "6:30pm" became 18:30. You can now pass order.items straight to the POST /orders endpoint of the Brew & Bean API without any text parsing.

Step 3: Validate the values, not just the shape

The schema guarantees the form. It cannot guarantee the truth. A model can still read the wrong quantity or assume a time. Add your own checks in code, and ask a human or the customer when something is off.

check_order.py
from datetime import datetime

from parse_order import parse_order


def check(order):
    problems = []
    if not order.items:
        problems.append("No valid items found")
    if order.pickup_time:
        try:
            datetime.strptime(order.pickup_time, "%H:%M")
        except ValueError:
            problems.append("Pickup time is not HH:MM")
    if order.unclear:
        problems.append("Needs follow-up: " + ", ".join(order.unclear))
    return problems


order = parse_order("one filter coffee please")
issues = check(order)
print("OK" if not issues else issues)

Business rules live in your code. If check finds problems, reply to the customer instead of placing the order.

Validate before you act

Treat model output like user input: never trust it. In particular, never let the model decide prices or totals. The server must compute the total from its own menu, as in the Brew & Bean API design.

Using a plain JSON schema (any language)

Pydantic is a Python convenience. Underneath, the API accepts a JSON Schema, so you can write it by hand or generate it from Zod in JavaScript. The SDK accepts a dictionary of the schema as well. Only a subset of JSON Schema is supported, and complex features such as deep recursion may be rejected, so keep schemas simple and flat where you can.

parse_order.mjs
import { GoogleGenAI, Type } from "@google/genai";

const ai = new GoogleGenAI({});

const response = await ai.models.generateContent({
  model: "gemini-flash-latest", // use a current model ID
  contents: "Two masala chai for Meena at 5pm",
  config: {
    responseMimeType: "application/json",
    responseSchema: {
      type: Type.OBJECT,
      properties: {
        customer_name: { type: Type.STRING, nullable: true },
        items: {
          type: Type.ARRAY,
          items: {
            type: Type.OBJECT,
            properties: {
              item_id: { type: Type.STRING, enum: ["espresso", "masala-chai", "croissant"] },
              qty: { type: Type.INTEGER },
            },
            required: ["item_id", "qty"],
          },
        },
      },
      required: ["items"],
    },
  },
});

const order = JSON.parse(response.text);
console.log(order);

The same idea in the JavaScript SDK. Run with node --env-file=.env parse_order.mjs.

Common problems

Troubleshooting

ProblemCauseFix
response.parsed is NoneOutput was cut off by max_output_tokens, or the schema was rejectedRaise the token cap; simplify the schema; print response.text
Wrong values in a valid shapeThe model guessedUse enums, add descriptions, add a null option, validate in code
Model invents a value for a missing fieldRequired field with no way to say "unknown"Make it optional or nullable and say "null if not mentioned"
Very slow or rejected schemaToo deep or too largeSplit into two smaller calls
Output not used by streaming UIPartial JSON is unparseableUse a normal call for JSON

How this comes up in interviews

Typical questions: "How do you make LLM output reliable enough for a program?" and "What if the model returns invalid data?" A good answer: constrain output with a schema, validate with a typed model, keep business rules in code, allow "unknown" instead of forcing a guess, retry once with the error message, and fall back to a human or a clarifying question.

Prompt · Design a schema for my feature
I am building [feature, e.g. a resume parser / support ticket classifier]. Design a Pydantic model (Python) for the structured output I should request from an LLM. Use enums for fixed choices, make uncertain fields optional, add a short description for every field, and list 5 test inputs, including 2 tricky ones where information is missing.

What to expect: Check that every field can honestly be filled from the input, that unknown is possible, and that the enum values match your database.

Try it: Add a retry that shows the error

Write parse_with_retry(text) that calls parse_order. If it raises an error or check(order) reports problems other than unclear, call the model once more with the original text plus a sentence listing the problems, and return the better result.

Key takeaways

  1. Structured output makes the API return JSON that matches a schema, which is far safer than asking nicely in the prompt.
  2. Describe the shape with Pydantic (or JSON Schema); use enums for fixed values and field descriptions as mini-instructions.
  3. In Python, response_mime_type="application/json" plus response_schema gives you response.parsed.
  4. The shape is guaranteed, the truth is not: validate values and keep business rules, prices and totals in your code.
  5. Let fields be null when information is missing, so the model need not guess.
  6. Keep schemas simple, and do not stream JSON you need to parse.

Check yourself

Finishing the quiz marks the lesson as done.

1. What does a response schema guarantee?

2. Why make customer_name optional and nullable?

3. Which field type best stops the model returning a menu item that does not exist?

4. response.parsed comes back as None. What is a likely cause?

5. Who should compute the order total in the Brew & Bean app?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free