Chapter 5 of 21 · basic
Structured output and JSON mode
What this session covers
Every chapter so far ended with the model handing you a string. That's fine when a human reads it. It's a problem the moment another piece of code has to. I've lost more hours than I'll admit to a regex that parsed the model's answer perfectly until the day it wrote "Sure! Here's the JSON:" in front of it. This hour kills that whole class of bug. You hand the model a schema, and the SDK hands you back an object that already matches it — typed, validated, no parsing.
The schema is the contract. You declare the shape once as a Pydantic model, and both providers enforce it: the right fields, the right types, a list where you asked for a list. The basic example extracts a calendar event from a sentence into a real object, so the difference from "give me some JSON" is concrete from the first run.
OpenAI
The Responses API has a parse helper built for this. Pass your Pydantic model
as text_format and the model's reply comes back already validated on
output_parsed — an actual CalendarEvent, not a string you have to
json.loads and pray over. If the model can't satisfy the schema the call
fails loudly instead of handing you malformed text that breaks three functions
downstream.
"""Week 4 - Structured output (OpenAI).
Stop parsing prose. Hand the model a schema and get back an object whose
shape you can rely on. Here the schema is a Pydantic model; the Responses
API `parse` helper validates the model's reply against it and gives you a
typed object, not a string you have to pick apart.
"""
import os
import sys
from dotenv import find_dotenv, load_dotenv
from openai import OpenAI
from pydantic import BaseModel
load_dotenv(find_dotenv())
if not os.environ.get("OPENAI_API_KEY"):
sys.exit("OPENAI_API_KEY is not set. Put it in the course-root .env file.")
client = OpenAI()
# region: schema
# The schema is the contract. The model must return exactly these fields,
# with these types — participants is a list of strings, not a comma blob.
class CalendarEvent(BaseModel):
name: str
date: str
participants: list[str]
# endregion
TEXT = "Lunch with Ana and Diego next Friday to go over the Q3 roadmap."
# region: parse-call
# `responses.parse` with `text_format` forces the reply to match the model
# and returns it already parsed on `output_parsed` — a real CalendarEvent.
response = client.responses.parse(
model="gpt-5.4-nano",
input=f"Extract the calendar event from this text:\n{TEXT}",
text_format=CalendarEvent,
)
event = response.output_parsed
# endregion
print(type(event).__name__, "->")
print(" name:", event.name)
print(" date:", event.date)
print(" participants:", event.participants)
The thing to internalize is that the schema isn't a suggestion in the prompt —
it's enforced at decode time. participants is typed as list[str], so you get
a list, not "Ana, Diego" that you then have to split and worry about the Oxford
comma. The types you declare are the types you receive. That's the whole reason
to reach for this over asking nicely for JSON in the prompt.
Gemini
Same Pydantic model, same guarantee, attached differently. Gemini takes the
schema on its GenerateContentConfig as response_schema, and you set
response_mime_type to application/json so it knows to emit JSON at all. The
validated object comes back on response.parsed; the raw JSON text is still on
response.text if you want it.
"""Week 4 - Structured output (Gemini).
Same schema, same job, so the two SDKs read side by side. Gemini takes the
schema on its config as `response_schema` and, with the JSON mime type set,
hands the parsed object back on `response.parsed`.
"""
import os
import sys
from dotenv import find_dotenv, load_dotenv
from google import genai
from google.genai import types
from pydantic import BaseModel
load_dotenv(find_dotenv())
if not os.environ.get("GEMINI_API_KEY"):
sys.exit("GEMINI_API_KEY is not set. Put it in the course-root .env file.")
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
# region: schema
class CalendarEvent(BaseModel):
name: str
date: str
participants: list[str]
# endregion
TEXT = "Lunch with Ana and Diego next Friday to go over the Q3 roadmap."
# region: parse-call
# response_mime_type pins JSON; response_schema pins the shape. Gemini
# returns the validated object on `.parsed`, the raw JSON on `.text`.
response = client.models.generate_content(
model="gemini-3.1-flash-lite",
contents=f"Extract the calendar event from this text:\n{TEXT}",
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=CalendarEvent,
),
)
event = response.parsed
# endregion
print(type(event).__name__, "->")
print(" name:", event.name)
print(" date:", event.date)
print(" participants:", event.participants)
One schema definition, two SDKs, and again the only real difference is where the
schema attaches: a text_format argument in OpenAI, a response_schema config
field in Gemini. Write the Pydantic model once and you can point either provider
at it. The shape of your data stops being a per-provider concern, which is
exactly what you want when you're swapping models around later.
Put it to work
Three docker-compose web apps under code/showcase/<slug>/, same drill as
before: bash bootstrap-secrets.sh, docker compose up --build,
http://localhost:3000. Each one returns JSON you could feed straight into the
next system instead of a paragraph you'd have to scrape.
Showcase 1 — Extract fields
Paste a messy blob — an email signature, a forwarded intro — and get back a clean contact object: name, email, phone, company. The fields are optional in the schema, so anything the text doesn't mention comes back null instead of the model inventing a plausible phone number to fill the gap. That null-instead-of- guess behavior is a direct consequence of how you typed the schema.
Showcase 2 — Classify JSON
Week two coaxed a label out as text and hoped it stayed on format. This does the
same classification, but the schema makes the format a guarantee: a label from a
fixed set, a confidence float, a list of reasons. You can branch on
result.label without checking whether the model wrapped it in a sentence
first, because the field is structurally always there.
Showcase 3 — Form filler
The one closest to real work. A one-line expense note becomes a structured row
ready to insert — merchant, amount as a number, currency, a category from your
set, a date. The schema does double duty here: it shapes the request and it
validates the reply, so amount is a float you can sum and category is one of
yours, not free text you have to reconcile later.
All three run the same backend/main.py, byte for byte, dispatching on
PROVIDER, so PROVIDER=gemini docker compose up --build swaps the SDK without
touching the lesson. The one design choice that keeps these honest is that the
return value is validated data, not text that happens to look like data — the
difference you only notice on the day the model decides to be chatty.
Run it
The README in this folder has the Python version, the install line, the two
environment variables, and the exact command for the basic examples and each
showcase. Keys come from the untracked .env at the course root, same as every
other week.
Takeaways
The moment a model's output feeds code instead of a person, stop asking for JSON in the prose and start declaring a schema. It turns "usually parseable" into "validated or it failed" — and "usually" is the word that pages you at 2am. Write the shape once as a Pydantic model, hand it to either provider, and read a typed object back. Extraction, classification, filling a record: same move every time. Everything the later agent chapters do with tool calls is this same machinery pointed at a different target, so getting it reflexive now pays off for the rest of the book.