Every production LLM pipeline I have seen breaks in the same place: the moment raw model text meets JSON.parse. The model returns almost-valid JSON with a trailing comma, invents a field your database does not have, or quietly drops a required key — and your pipeline dies at 3 AM.
In this tutorial I show you the 2026 way out: structured outputs with schema guarantees. OpenAI strict mode, Vercel AI SDK generateText with Output + Zod, Anthropic strict tool use, and Pydantic validation with instructor — all with real code I verified against the official docs.
1. The Problem: JSON.parse Is Not a Strategy
Left to its own devices, an LLM generates text that looks like JSON but carries no guarantees. A missing required field, a number returned as a string, an enum value you never defined, one extra key your ORM rejects — any of these turns a demo into an incident. Prompting harder (’return ONLY valid JSON’) reduces the failure rate but never eliminates it, because nothing in the decoding process actually forbids invalid tokens.
The Most Common Mistake
Shipping JSON mode and calling it done. OpenAI’s JSON mode (response_format: { type: ’json_object’ }) guarantees the output parses as JSON — and nothing else. Field names, types, required keys and enum values are still up to the model’s mood. For anything downstream code consumes by name, you need Structured Outputs, not JSON mode.
Structured outputs fix this at the sampling layer: with constrained decoding, the provider compiles your JSON Schema into a grammar and the model physically cannot emit tokens that violate it. Every required key present, every type matching, every enum value from your list. That guarantee — available on OpenAI, Anthropic and through libraries like the Vercel AI SDK and instructor — is what this tutorial is about.
2. Minimum Concepts Before Touching Code
Six ideas cover 90% of what you need. Get these straight and every code sample below will read like plain English.
Constrained decoding
the core mechanism
Your schema is compiled into a grammar that restricts which tokens the model may emit. Schema adherence is enforced during generation, not checked afterwards.
Strict JSON Schema subset
rules you must follow
Root must be an object, every property listed in required, additionalProperties: false on every object. Value-level keywords like pattern or minimum are not enforced — validate those yourself.
strict: true
the flag that matters
On OpenAI (response_format json_schema) and Anthropic (tool definitions) this single flag switches from ’model tries its best’ to ’model cannot violate the schema’. Always set it.
Refusals
the deliberate exception
When the model refuses for safety reasons, the output will not match your schema on purpose. OpenAI returns a refusal field; Anthropic a refusal stop reason. Branch on it before parsing.
Response format vs tool use
two doors, same guarantee
Use response_format (or output_config) when the model answers you directly in JSON; use strict tools when the model calls your functions. Both give schema guarantees on supported models.
Validate anyway
trust, then verify
Strict mode guarantees shape, not semantics: an end date before the start date still parses. Keep Pydantic validators and Zod refinements for business rules, plus a conformance test in CI.
The mental model to keep
JSON mode says ’the model tries to output JSON, verify it yourself’. Structured outputs say ’the decoder cannot emit tokens that violate your schema’. One is a promise, the other is physics. Default to structured outputs for every new integration in 2026.
3. Step 1 — OpenAI Structured Outputs (Python + curl)
OpenAI exposes structured outputs in two equivalent ways: declare the raw JSON Schema in response_format with strict: true, or hand the SDK a Pydantic model and let the parse helper derive the schema and return a typed object. The second form is what I recommend — no json.loads, no manual validation, and refusals surface as a first-class field.
Python — typed parse with Pydantic (recommended)
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI()
class SupportTicket(BaseModel):
summary: str
category: str
severity: int
account_id: str | None
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "Extract the ticket into the schema."},
{"role": "user", "content": "My checkout 500s with a saved card. Started today. Account: acct_8842."},
],
response_format=SupportTicket,
)
ticket = completion.choices[0].message
if ticket.refusal:
print("Refused:", ticket.refusal)
else:
print(ticket.parsed) # SupportTicket instance, schema-guaranteedPrefer raw HTTP or another language? The same guarantee is one response_format block away. Note the three strict-mode rules inside the schema: every property listed in required, additionalProperties false, and enums expressed explicitly. On newer flagships (the GPT-5 series supports strict mode too) the shape is identical — only the model id changes, so confirm strict support on your pinned snapshot in the docs.
curl — Chat Completions with json_schema, strict true
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-2024-08-06",
"messages": [
{"role": "system", "content": "Extract the ticket into the schema."},
{"role": "user", "content": "My checkout 500s with a saved card. Account: acct_8842."}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "support_ticket",
"strict": true,
"schema": {
"type": "object",
"properties": {
"summary": {"type": "string"},
"category": {"type": "string", "enum": ["billing", "bug", "account", "other"]},
"severity": {"type": "integer"},
"account_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}
},
"required": ["summary", "category", "severity", "account_id"],
"additionalProperties": false
}
}
}
}'Tip: nullable, not optional
Strict mode has no concept of ’optional field’: every property must appear in required. To allow a missing value, union the type with null (anyOf with null, or str | None in Pydantic) instead of omitting the key. A schema that breaks this rule fails with a 400 before any token is generated.
4. Step 2 — Vercel AI SDK generateText with Output + Zod
If you live in TypeScript, the Vercel AI SDK standardises structured generation across providers through generateText with an output spec. You pass Output.object({ schema }) with your Zod schema, a prompt and a model; you get back a fully typed output. The schema doubles as the validator, so the type you see in your editor is the shape the model was constrained to. Note: generateObject and streamObject are deprecated since AI SDK 6 (PR #10754) and will be removed in a future version — generateText/streamText with Output is the replacement.
TypeScript — generateText with Output.object() and Zod
import { generateText, Output } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const { output } = await generateText({
model: openai("gpt-4o-2024-08-06"),
output: Output.object({
schema: z.object({
summary: z.string(),
category: z.enum(["billing", "bug", "account", "other"]),
severity: z.number(),
accountId: z.string().nullable(),
}),
}),
prompt: "Classify this ticket: My checkout 500s with a saved card. Account acct_8842.",
});
console.log(output.category); // typed: "billing" | "bug" | "account" | "other"Two gotchas that bite everyone once
First, when the model cannot produce a valid object, generateText with a structured output rejects with AI_NoObjectGeneratedError — catch it and retry or fall back, never assume output exists. Second, with OpenAI models use .nullable(), never .optional() or .nullish(): the latter two generate JSON Schema patterns outside OpenAI’s supported subset and the call fails with a content-filter finish reason.
Arrays and choices — same function, Output spec
// Array of objects: element describes ONE element
const { output: heroes } = await generateText({
model: openai("gpt-4o-2024-08-06"),
output: Output.array({
element: z.object({ name: z.string(), description: z.string() }),
}),
prompt: "Generate 3 hero descriptions for a fantasy game.",
});
// Classification: fixed set of values, no schema
const { output: genre } = await generateText({
model: openai("gpt-4o-2024-08-06"),
output: Output.choice({
options: ["action", "comedy", "drama", "horror", "sci-fi"],
}),
prompt: "Classify this plot: astronauts cross a wormhole seeking a new home.",
});5. Step 3 — Anthropic Strict Tools + Instructor with Pydantic
Two more paths round out the toolkit. On Anthropic, strict tool use (strict: true on the tool definition) compiles your input_schema into a grammar, so the tool_use block’s input is guaranteed schema-valid and the tool name is always one you defined. In Python generally, the instructor library gives you the same typed experience as the OpenAI parse helper — but uniformly across 15+ providers with automatic retries on validation failure.
Python — Anthropic strict tool use
import anthropic
client = anthropic.Anthropic()
tools = [
{
"name": "log_ticket",
"description": "Log a classified support ticket.",
"strict": True,
"input_schema": {
"type": "object",
"properties": {
"summary": {"type": "string"},
"category": {"type": "string", "enum": ["billing", "bug", "account", "other"]},
"severity": {"type": "integer"},
},
"required": ["summary", "category", "severity"],
"additionalProperties": False,
},
}
]
response = client.messages.create(
model="claude-opus-4-7",
max_tokens=1024,
tools=tools,
messages=[{"role": "user", "content": "My checkout 500s with a saved card. Log it as a bug."}],
)
for block in response.content:
if block.type == "tool_use":
print(block.name, block.input) # input guaranteed schema-validAnd when you want provider independence — same code against OpenAI, Anthropic, Gemini or a local model — instructor’s from_provider is the thinnest layer that still gives you typed objects. Define a Pydantic model, pass it as response_model, and you get the instance back directly, with max_retries handling the cases where validation fails.
Python — instructor with Pydantic (any provider)
import instructor
from pydantic import BaseModel
client = instructor.from_provider("openai/gpt-4o-mini")
class User(BaseModel):
name: str
age: int
user = client.chat.completions.create(
response_model=User,
messages=[{"role": "user", "content": "John is 25 years old"}],
max_retries=3,
)
print(user) # User(name="John", age=25), fully typedWatch the schema subset on Anthropic too
Strict tools accept a subset of JSON Schema draft 2020-12: no top-level unions, no numeric or string constraints (minimum, maxLength), no pattern, and additionalProperties must be false. The official SDKs strip unsupported keywords and re-validate client-side — but hand-rolled schemas outside the subset fail with a 400 before the model runs. Keep schemas small and move conditional logic into your handler.
6. A Validation Routine That Actually Holds Up
Schema guarantees remove an entire class of bugs, but they do not remove engineering discipline. This is the routine I run on every structured-output integration:
🔁 Every response (milliseconds)
- 1. Check the refusal path first: message.refusal (OpenAI) or stop_reason refusal (Anthropic) before touching parsed data
- 2. Let the typed object do its job: ticket.parsed, result.output — never re-parse raw JSON strings by hand
- 3. Enforce business rules post-parse: date ranges, cross-field consistency, value ranges the schema subset cannot express
🧪 Every deploy (CI, minutes)
- Conformance test: assert one live response per schema against the exact JSON Schema; fail the build on drift
- Refusal drill: send one adversarial prompt per schema and assert your code takes the refusal branch cleanly
- Model pin check: verify the pinned snapshot still supports strict mode — support tracks model versions, not your code
🧹 Every schema change
- Subset audit: new field? It goes in required, gets additionalProperties: false on its object, and uses nullable instead of optional
- First-request latency: new schemas pay a one-time compilation cost (a few hundred ms) and are then cached — warm them before measuring
- Migrate stragglers: every remaining json_object call with a known shape becomes a strict schema; JSON mode stays only for genuinely schema-free cases
7. Common Errors (All of Them Fixable in Minutes)
Almost every structured-output failure I have debugged falls into one of these buckets. The left column is what working integrations do; the right column is what pages you at night.
✅ Do this
- • Set strict: true on every response_format and every tool definition
- • List all fields in required; model absence with nullable unions
- • Set additionalProperties: false on every object, nested ones included
- • Handle refusals and max_tokens truncation before parsing
- • Keep Pydantic/Zod validators for semantics the schema cannot express
- • Pin the model snapshot and re-verify strict support on upgrade
❌ Not this
- • ’Return ONLY valid JSON’ in the system prompt instead of a schema
- • JSON mode (json_object) for payloads downstream code consumes by name
- • .optional() / .nullish() in Zod schemas targeting OpenAI strict mode
- • pattern, minimum or minLength doing load-bearing enforcement
- • Top-level arrays or unions as the schema root — wrap them in an object
- • Assuming the first request’s latency is the steady-state latency
Golden rule
If you know the field names, there is no reason to accept invalid JSON in 2026. Strict schemas for known shapes, JSON mode only for genuinely free-form payloads, and a validator plus a test for everything that reaches production.
Conclusion
Structured outputs turn the flakiest part of LLM apps — ’please return JSON’ — into a typed contract enforced at generation time. You saw the full loop: strict response_format on OpenAI with the parse helper, generateText with Output.object() and Zod on the Vercel AI SDK, strict tool use on Anthropic, and provider-independent extraction with instructor and Pydantic.
Pick one path this week and migrate a single JSON.parse call site: define the schema, flip strict on, add the refusal branch, and keep one conformance test. That one migration usually deletes more error-handling code than any other change of its size — and it is the foundation every agent, extractor and tool loop you build next will stand on.
Tutorial Cheat Sheet
OpenAI
- • parse helper + Pydantic model
- • strict: true + full required
- • branch on message.refusal
Vercel + Anthropic
- • generateText + Output.object()
- • .nullable(), never .optional()
- • strict tools + input_schema
Always
- • Validate semantics post-parse
- • Conformance test in CI
- • Pin + verify model support
Sources
- OpenAI — Structured Outputs guide (response_format, strict mode, refusals)
- OpenAI — Introducing Structured Outputs in the API
- Vercel AI SDK — Generating Structured Data (generateText, Output, Zod)
- Vercel AI SDK — Migration guide 6.0 (generateObject/streamObject deprecation, PR #10754)
- Anthropic — Strict tool use (grammar-constrained input_schema)
- Instructor — Structured outputs with OpenAI (from_provider, response_model)



