Course ES

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.