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
| Approach | Reliability | Notes |
|---|---|---|
| "Reply in JSON" in the prompt | Medium | Needs cleaning and retries |
| JSON mode only (valid JSON, no schema) | Better | Valid syntax but keys may vary |
| JSON mode plus a schema | Best | Shape 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.
pip install -U google-genai pydantic python-dotenvfrom 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
- 1Describe the data you want as a Pydantic class (Step 1).
- 2Pass that class as
response_schemawithresponse_mime_type="application/json"(Step 2). - 3Read
response.parsedto get a typed object. - 4Validate the values with your own checks (Step 3).
Step 2: Ask for output that matches the schema
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.
{
"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.
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.
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
| Problem | Cause | Fix |
|---|---|---|
response.parsed is None | Output was cut off by max_output_tokens, or the schema was rejected | Raise the token cap; simplify the schema; print response.text |
| Wrong values in a valid shape | The model guessed | Use enums, add descriptions, add a null option, validate in code |
| Model invents a value for a missing field | Required field with no way to say "unknown" | Make it optional or nullable and say "null if not mentioned" |
| Very slow or rejected schema | Too deep or too large | Split into two smaller calls |
| Output not used by streaming UI | Partial JSON is unparseable | Use 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.
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
- Structured output makes the API return JSON that matches a schema, which is far safer than asking nicely in the prompt.
- Describe the shape with Pydantic (or JSON Schema); use enums for fixed values and field descriptions as mini-instructions.
- In Python,
response_mime_type="application/json"plusresponse_schemagives youresponse.parsed. - The shape is guaranteed, the truth is not: validate values and keep business rules, prices and totals in your code.
- Let fields be null when information is missing, so the model need not guess.
- 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.