M02 — Structured Output
A friendly paragraph is great for a human. It's useless to a database, a UI card, or the next service in your pipeline — they need guaranteed JSON in a known shape. In this module you make Acme Support return a typed OrderStatus object that validates against a schema every single time, in all three SDKs.
Learning Objectives
- Explain why "just ask for JSON in the prompt" fails in production, and what schema-enforced output guarantees instead
- Define one
OrderStatusshape as a PydanticA Python library that defines data shapes as typed classes and validates data against them. The SDKs turn your Pydantic model into a JSON schema automatically. model (Python) or a ZodThe equivalent library for TypeScript/JavaScript: define a schema as code, get a typed value out, validated at runtime. schema (JS) - Force schema-conforming output using each SDK's
parsehelper — and know the raw JSON-schema escape hatch - Read the result as a typed object, not a string you have to
json.loadsand pray over - Handle the two things that still break structure: a safety refusal and a truncated (max-tokens) response
Why Structured Output?
BEFORE: Imagine your warehouse asks new hires to report each shipment. One writes a warm note: "Hey! So AC-1042 went out with UPS, should land Tuesday I think 🙂". Another writes: "1042 / UPS / Tue". A third writes a full paragraph with the tracking story of their week.
PAIN: Now you have to build a dashboard from these reports. Every note is phrased differently — some bury the date, some omit the carrier, one spells the status "shipped-ish". Your dashboard code becomes a nightmare of guesswork, and it breaks the first time someone phrases things a new way.
MAPPING: A language model's default output is that warm freeform note — lovely for a customer, hostile to a program. Structured output is handing every hire the same fillable form: order id, status (pick one of four), carrier, ETA, summary. Now every report has the same fields in the same places, and your dashboard just reads the boxes. Same model, but the output is a form, not a letter.
The moment an agent's answer feeds anything other than a human eyeball — a database write, a UI component, an if statement, a webhook — you need structure. Teams that skip this write brittle regex to scrape fields out of prose, and page on-call every time the model rephrases. Schema-enforced output turns "parse the model's mood" into "read order.status." It's the difference between a demo and a product.
Two Ways to Get JSON — Only One Is Safe
1. Prompt-and-pray. You write "respond in JSON like {...}" in the prompt and hope. This works most of the time — and fails exactly when you're not watching: the model wraps the JSON in ```json fences, adds a "Here's the data:" preamble, invents an extra field, or emits an invalid enum. Your json.loads throws in production at 2am.
2. Schema-enforced (structured output). You hand the SDK an actual schema. The provider constrains generation so the output is guaranteed to be valid JSON matching your shape — right types, required fields present, enums honored. No fences, no preamble, no surprises. All three SDKs support this natively, and each ships a parse helper that hands you back a typed object, already validated. This is the only approach you should ship.
Here's the idea as a picture: the model still reasons in freeform, but the schema acts as a funnel — only output that fits the shape gets through. Watch the messy note on the left become five typed fields on the right:
The status field became exactly "shipped" — not "shipped-ish", not "on its way", not "🙂". Because we declared status as an enum of four allowed values, the model couldn't emit anything else. That's the superpower: structure isn't just "valid JSON," it's "valid JSON that matches your rules." Your downstream if order.status == "shipped" can never miss.
Step 1 — Define the Shape
First, the schema — the same OrderStatus shape in each language's tooling. Note status is an enum: the model must pick one of the four allowed strings. This is provider-agnostic within a language, so all three Python examples reuse the Pydantic model, and the JS examples reuse the Zod schema.
# The one shape, reused by all three Python SDKs.
from pydantic import BaseModel, Field
from typing import Literal
class OrderStatus(BaseModel):
order_id: str
status: Literal["processing", "shipped", "delivered", "cancelled"] # enum!
carrier: str = Field(description='Carrier name, or "unknown" if none yet')
eta: str
summary: str = Field(description="One friendly sentence for the customer")
// The one shape, reused by the Anthropic + OpenAI JS SDKs (Zod).
import { z } from "zod";
export const OrderStatus = z.object({
order_id: z.string(),
status: z.enum(["processing", "shipped", "delivered", "cancelled"]), // enum!
carrier: z.string().describe('Carrier name, or "unknown" if none yet'),
eta: z.string(),
summary: z.string().describe("One friendly sentence for the customer"),
});
In strict structured output, every field is required and objects forbid extra properties (additionalProperties: false). You can't just leave a field off and hope. If a value might be missing, model it explicitly — either give it a sentinel like "unknown" (what we do for carrier), or make it a nullable union (Optional[str] / z.string().nullable()). The Pydantic and Zod helpers generate the strict schema for you; if you hand-write raw JSON schema, you must set these yourself.
Step 2 — Force the Shape
Now feed the model some raw, messy order data and demand an OrderStatus back. Each tab is a complete, runnable file. The pattern is: attach the schema, call the SDK's parse helper, read a typed object. Flip between providers — the schema plugs into a different parameter each time, but the payoff (a validated object) is identical.
# structured_anthropic.py
from anthropic import Anthropic
from pydantic import BaseModel, Field
from typing import Literal
class OrderStatus(BaseModel):
order_id: str
status: Literal["processing", "shipped", "delivered", "cancelled"]
carrier: str = Field(description='Carrier name, or "unknown" if none yet')
eta: str
summary: str = Field(description="One friendly sentence for the customer")
client = Anthropic()
RAW = 'AC-1042 went out with UPS, should land Tuesday, all good on our end'
resp = client.messages.parse(
model="claude-sonnet-5", max_tokens=512,
system="You are Acme Support. Turn raw order notes into a clean OrderStatus.",
messages=[{"role": "user", "content": f"Raw note: {RAW}"}],
output_format=OrderStatus, # <-- Pydantic model goes in output_format
)
order = resp.parsed_output # <-- a validated OrderStatus instance
print(order.status, "|", order.summary)
// structured_anthropic.mjs
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
const OrderStatus = z.object({
order_id: z.string(),
status: z.enum(["processing", "shipped", "delivered", "cancelled"]),
carrier: z.string(),
eta: z.string(),
summary: z.string(),
});
const client = new Anthropic();
const RAW = "AC-1042 went out with UPS, should land Tuesday, all good on our end";
const resp = await client.messages.parse({
model: "claude-sonnet-5", max_tokens: 512,
system: "You are Acme Support. Turn raw order notes into a clean OrderStatus.",
messages: [{ role: "user", content: `Raw note: ${RAW}` }],
output_config: { format: zodOutputFormat(OrderStatus) }, // <-- inside output_config
});
const order = resp.parsed_output; // <-- typed (nullable — guard in prod)
console.log(order.status, "|", order.summary);
# structured_gemini.py
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from typing import Literal
class OrderStatus(BaseModel):
order_id: str
status: Literal["processing", "shipped", "delivered", "cancelled"]
carrier: str = Field(description='Carrier name, or "unknown" if none yet')
eta: str
summary: str = Field(description="One friendly sentence for the customer")
client = genai.Client()
RAW = "AC-1042 went out with UPS, should land Tuesday, all good on our end"
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents=f"Turn this raw order note into a clean OrderStatus: {RAW}",
config=types.GenerateContentConfig(
system_instruction="You are Acme Support.",
response_mime_type="application/json", # <-- required
response_schema=OrderStatus, # <-- Pydantic model
),
)
order = resp.parsed # <-- a validated OrderStatus (resp.text = raw JSON)
print(order.status, "|", order.summary)
// structured_gemini.mjs
// Gemini's JS SDK has no typed parse helper — you supply a raw schema with the
// Type enum and JSON.parse the text yourself.
import { GoogleGenAI, Type } from "@google/genai";
const ai = new GoogleGenAI({});
const RAW = "AC-1042 went out with UPS, should land Tuesday, all good on our end";
const resp = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: `Turn this raw order note into a clean OrderStatus: ${RAW}`,
config: {
systemInstruction: "You are Acme Support.",
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
order_id: { type: Type.STRING },
status: { type: Type.STRING, enum: ["processing", "shipped", "delivered", "cancelled"] },
carrier: { type: Type.STRING },
eta: { type: Type.STRING },
summary: { type: Type.STRING },
},
required: ["order_id", "status", "carrier", "eta", "summary"],
propertyOrdering: ["order_id", "status", "carrier", "eta", "summary"],
},
},
});
const order = JSON.parse(resp.text); // <-- plain object, guaranteed to fit the schema
console.log(order.status, "|", order.summary);
# structured_openai.py (Responses API)
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal
class OrderStatus(BaseModel):
order_id: str
status: Literal["processing", "shipped", "delivered", "cancelled"]
carrier: str = Field(description='Carrier name, or "unknown" if none yet')
eta: str
summary: str = Field(description="One friendly sentence for the customer")
client = OpenAI()
RAW = "AC-1042 went out with UPS, should land Tuesday, all good on our end"
resp = client.responses.parse(
model="gpt-5.5",
instructions="You are Acme Support. Turn raw order notes into a clean OrderStatus.",
input=f"Raw note: {RAW}",
text_format=OrderStatus, # <-- Pydantic model goes in text_format
)
order = resp.output_parsed # <-- a validated OrderStatus instance
print(order.status, "|", order.summary)
// structured_openai.mjs (Responses API)
import OpenAI from "openai";
import { z } from "zod";
import { zodTextFormat } from "openai/helpers/zod";
const OrderStatus = z.object({
order_id: z.string(),
status: z.enum(["processing", "shipped", "delivered", "cancelled"]),
carrier: z.string(),
eta: z.string(),
summary: z.string(),
});
const client = new OpenAI();
const RAW = "AC-1042 went out with UPS, should land Tuesday, all good on our end";
const resp = await client.responses.parse({
model: "gpt-5.5",
instructions: "You are Acme Support. Turn raw order notes into a clean OrderStatus.",
input: `Raw note: ${RAW}`,
text: { format: zodTextFormat(OrderStatus, "order_status") }, // <-- text.format
});
const order = resp.output_parsed; // <-- typed OrderStatus
console.log(order.status, "|", order.summary);
- Anthropic: Python
output_format=Model; JSoutput_config: { format: zodOutputFormat(Schema) }. Readparsed_output. - Gemini:
config.response_schema(+response_mime_type="application/json"). Python gives youresp.parsed; JS has no typed helper, so youJSON.parse(resp.text). - OpenAI: Python
text_format=Model; JStext: { format: zodTextFormat(Schema, "name") }. Readoutput_parsed.
Run It
shipped | Your order AC-1042 shipped via UPS and should arrive Tuesday.
# and the full validated object behind it:
OrderStatus(order_id="AC-1042", status="shipped", carrier="UPS",
eta="Tuesday", summary="Your order AC-1042 shipped via UPS...")
json.loads, never stripped a ```json fence, never checked for a preamble. You got an object with an .status attribute that is provably one of four values. Try feeding a note about a cancelled order — status flips to "cancelled", and it can never be a fifth made-up value.
Refusals, Enums & Nullables
Schema enforcement guarantees shape, but two things can still hand you something that isn't your object. Handle both:
- Refusal. If the model declines for safety reasons, it returns a refusal instead of schema-conforming output. Always check first: OpenAI Chat exposes
message.refusal; the Responses API and Anthropic surface a refusal viastop_reason/ a refusal item. If refused,parsed_outputmay be empty — don't blindly trust it. - Truncation. If the reply hits
max_tokensmid-object, you get half a JSON object — which won't parse. Give structured calls enoughmax_tokensheadroom, and treat an incomplete/failed parse as a retry, not a crash.
The single highest-value move in structured output is turning free strings into enums. status: Literal[...] means your code never sees "shipped-ish" or "on its way." Downstream branching, filtering, and analytics all become reliable. Wherever a field has a known set of values — status, priority, category, sentiment — make it an enum, not a str. Nullable fields (Optional[str] / z.string().nullable()) are the other essential tool: they let a field be "known-absent" without breaking strict mode.
Not every model supports strict structured output. On Anthropic it's the current tiers (Sonnet 5, Opus 4.8, Haiku 4.5); on OpenAI it's structured-outputs-capable models (our default gpt-5.5 qualifies); Gemini supports it across the 2.5 family. If you pin an older or unusual model and structured output silently degrades to prose, check that model's support page.
Three Ways, One Idea
| Concept | Anthropic | Google Gemini | OpenAI (Responses) |
|---|---|---|---|
| Parse method (Py) | messages.parse() | generate_content() | responses.parse() |
| Schema param (Py) | output_format=Model | config.response_schema=Model | text_format=Model |
| Schema param (JS) | output_config.format = zodOutputFormat(S) | config.responseSchema = {…Type…} | text.format = zodTextFormat(S,"n") |
| Also need (Gemini) | — | response_mime_type: "application/json" | — |
| Read typed result | resp.parsed_output | Py resp.parsed / JS JSON.parse(resp.text) | resp.output_parsed |
| Typed helper in JS? | Yes (Zod) | No — parse text yourself | Yes (Zod) |
| Raw escape hatch | output_config.format json_schema | response_json_schema | text.format json_schema |
All three constrain generation to your schema — the divergence is purely ergonomic. Anthropic and OpenAI ship first-class typed parse helpers in both languages (Pydantic & Zod), so you rarely touch raw JSON schema. Gemini's Python SDK takes a Pydantic model too, but its JS SDK asks you to describe the schema with its Type enum and parse the text yourself — one extra line. Under the hood it's the same guarantee: output that provably fits the shape.
Knowledge Check
Q1: Why is "just ask for JSON in the prompt" risky in production?
Q2: You declare status as an enum of four values. What does that guarantee?
status will be exactly one of those four strings — never a fifth or a rephrasingif order.status == "shipped") bulletproof. This is the single highest-value structured-output move.Q3: In the Anthropic Python SDK, which parameter takes your Pydantic model, and where's the result?
response_schema=, read resp.parsedtext_format=, read resp.output_parsedoutput_config=, read resp.textoutput_format=, read resp.parsed_outputoutput_format= on messages.parse() and returns parsed_output. (Watch the JS asymmetry: there the Zod helper goes inside output_config.format.) Options A, B are Gemini and OpenAI respectively.Q4: Gemini's JavaScript SDK differs from the others how?
Type enum and JSON.parse(resp.text)Type-enum schema and a JSON string to parse — one extra line, same guarantee.Q5: A field might legitimately be absent. In strict mode you should…
Optional[str] / .nullable()) or a sentinel like "unknown"Module Summary
Key Takeaways
- Prompt-and-pray fails in production; schema-enforced output is the only shippable way to get JSON.
- Define the shape once (Pydantic in Python, Zod in JS) and plug it into each SDK's
parsehelper — you get a typed, validated object, nojson.loads. - The schema plugs into a different parameter per SDK (
output_format/response_schema/text_format), but the guarantee is identical. - Enums are the superpower: constrain fields to known values so downstream logic can never miss.
- Still handle refusals and truncation — structure guarantees shape, not that an object always comes back.
Next: M03 — Multi-Tool Orchestration
Acme can look up an order (M01) and answer in clean typed data (M02). Real support needs several tools — search products, process a refund, check an order — and the smarts to pick the right one, sometimes several at once. In M03 you give Acme a toolbox and watch each SDK orchestrate parallel tool calls in the loop you already know.