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 OrderStatus shape 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 parse helper — and know the raw JSON-schema escape hatch
  • Read the result as a typed object, not a string you have to json.loads and pray over
  • Handle the two things that still break structure: a safety refusal and a truncated (max-tokens) response

Why Structured Output?

Everyday Analogy

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.

Why It Matters

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

Technical Definition

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 Schema Funnel — Prose In, Typed Object Out
"Hey! So order AC-1042 went out with UPS, should land Tuesday I think 🙂 — all good on our end!"
order_id"AC-1042"
status"shipped"
carrier"UPS"
eta"Tuesday"
summary"Shipped via UPS…"
What Just Happened?

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"),
});
Strict-Mode Rule — No Silent Optionals

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);
Where the Schema Plugs In — the One Thing That Moves
  • Anthropic: Python output_format=Model; JS output_config: { format: zodOutputFormat(Schema) }. Read parsed_output.
  • Gemini: config.response_schema (+ response_mime_type="application/json"). Python gives you resp.parsed; JS has no typed helper, so you JSON.parse(resp.text).
  • OpenAI: Python text_format=Model; JS text: { format: zodTextFormat(Schema, "name") }. Read output_parsed.

Run It

Expected output (all six — a typed object, printed here)
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...")
✅ Checkpoint: Notice you never wrote 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:

Two Ways Structured Output Still “Fails”
  1. 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 via stop_reason / a refusal item. If refused, parsed_output may be empty — don't blindly trust it.
  2. Truncation. If the reply hits max_tokens mid-object, you get half a JSON object — which won't parse. Give structured calls enough max_tokens headroom, and treat an incomplete/failed parse as a retry, not a crash.
The Enum Is Your Best Friend

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.

Model Support Varies

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

ConceptAnthropicGoogle GeminiOpenAI (Responses)
Parse method (Py)messages.parse()generate_content()responses.parse()
Schema param (Py)output_format=Modelconfig.response_schema=Modeltext_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 resultresp.parsed_outputPy resp.parsed / JS JSON.parse(resp.text)resp.output_parsed
Typed helper in JS?Yes (Zod)No — parse text yourselfYes (Zod)
Raw escape hatchoutput_config.format json_schemaresponse_json_schematext.format json_schema
Why the Differences?

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?

AIt costs more tokens
BModels can't produce JSON at all without a schema
CIt usually works, but fails unpredictably — fences, preambles, extra fields, bad enums — and your parser throws at the worst time
DPrompts can't contain the word "JSON"
Correct! Prompt-and-pray works right up until it doesn't. Schema-enforced output constrains generation so the result is guaranteed valid and shaped correctly.

Q2: You declare status as an enum of four values. What does that guarantee?

AThe model runs faster
Bstatus will be exactly one of those four strings — never a fifth or a rephrasing
CThe summary field is also constrained
DNothing — enums are only documentation
Correct! An enum makes downstream logic (if 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?

Aresponse_schema=, read resp.parsed
Btext_format=, read resp.output_parsed
Coutput_config=, read resp.text
Doutput_format=, read resp.parsed_output
Correct! Python uses output_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?

ANo typed parse helper — you supply a raw schema with the Type enum and JSON.parse(resp.text)
BIt doesn't support structured output at all
CIt requires a Pydantic model in JavaScript
DIt returns XML instead of JSON
Correct! Anthropic and OpenAI ship Zod-based parse helpers in JS; Gemini's JS SDK gives you the raw 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…

ALeave it out of the schema and hope the model omits it
BDisable structured output for that call
CModel it explicitly — a nullable union (Optional[str] / .nullable()) or a sentinel like "unknown"
DMake it a number instead
Correct! Strict mode requires every field present, so "maybe absent" must be expressed as nullable or given a sentinel value. You can't rely on silent omission.

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 parse helper — you get a typed, validated object, no json.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.