LLM Structured Outputs: JSON Schema, Pydantic, Zod, Instructor
Asking a model to "reply in JSON" works until it does not: a missing field, a trailing comment, a number returned as a string, a polite sentence before the opening brace. Production code needs output it can parse and trust. There are three levels of help available: JSON mode, which only promises valid JSON; native structured outputs, where the provider constrains decoding so the response follows your JSON Schema; and validate-and-retry libraries such as Instructor, which check the result against a Pydantic or Zod model and ask again on failure.
This guide explains how each works, using OpenAI's and Anthropic's structured-output documentation and the Instructor docs: the request shapes, the SDK helpers for Pydantic and Zod, the JSON Schema subset the providers support, and the exceptions they document, because "always matches the schema" has fine print. For giving models tools rather than output formats, see MCP vs OpenAPI.
Three levels: JSON mode, constrained decoding, validation
JSON mode makes the model emit syntactically valid JSON. OpenAI's guide is explicit that it does not ensure schema adherence: fields can be missing, extra, or of the wrong type. It is a fallback for models or providers without native schema support.
Native structured outputs take a JSON Schema and constrain generation to it. OpenAI says Structured Outputs ensures the model always generates responses that adhere to the supplied schema; Anthropic says its structured outputs guarantee schema-compliant responses through constrained decoding, in most cases. Both document exceptions (next sections), and both support only a subset of JSON Schema.
Validate-and-retry runs your own validation (a Pydantic model or Zod schema, including rules no JSON Schema subset can express) and, on failure, sends the error back to the model and asks again. Instructor packages this pattern across providers.
They combine. Constrained decoding gets the shape right in one call; your validator checks the meaning: ranges, formats beyond what the provider supports, and rules across fields, such as an end date after a start date.
Quick reference
- Use native structured outputs whenever the provider and model support them; OpenAI recommends them over JSON mode when possible.
- Use function or tool calling when the model is choosing an action; use a response format when you are shaping the model's reply.
- Validation is still required with constrained decoding: a schema-valid object can contain invented values.
Remember this
JSON mode gives valid JSON, constrained decoding gives schema-shaped JSON, and your validator decides whether it is correct.
How constrained decoding works
A language model produces text one token at a time by sampling from a probability distribution over its vocabulary. Constrained decoding compiles your schema into a grammar and, at each step, masks out every token that would make the output impossible to complete validly. If the schema says the next thing must be the key "email" or a closing brace, no other token can be sampled. The model still chooses the values; the grammar only removes invalid paths.
Two practical consequences follow from that mechanism:
- Compilation has a cost. Both providers note extra latency on the first request with a new schema while it is processed. Anthropic caches compiled grammars for 24 hours from last use; changing the schema's structure or the set of tools recompiles, while changing only
nameordescriptionfields does not. - Not every schema can be compiled. Grammars cannot cheaply express some JSON Schema features, so providers support a subset and reject the rest with a 400 error.
Anthropic also notes that changing the output format invalidates the prompt cache for that conversation, so keep the schema stable within a thread.
Quick reference
- Constraint does not make the model smarter: if the input lacks the information, the model fills required fields with plausible guesses.
- Make fields optional (nullable) when the information may be absent, so the model has a valid way to say "not present".
- Very large or deeply branching schemas can hit compilation limits; Anthropic documents a 180-second compilation timeout.
Remember this
The schema becomes a grammar that removes invalid tokens; keep schemas stable and simple enough to compile.
Native structured outputs with OpenAI and Claude
OpenAI (Responses API): pass the schema under text.format with type: "json_schema" and strict: true. The official SDKs accept Pydantic models and Zod objects and parse the result:
1from openai import OpenAI2from pydantic import BaseModel3 4class Invoice(BaseModel):5 vendor: str6 total_cents: int7 currency: str8 due_date: str | None9 10client = OpenAI()11resp = client.responses.parse(12 model="<model>",13 input=[{"role": "user", "content": invoice_text}],14 text_format=Invoice,15)16invoice = resp.output_parsedClaude (Messages API): pass the schema under output_config.format with type: "json_schema" (no beta header is needed; the older output_format parameter is deprecated). Set strict: true on tool definitions to constrain tool inputs as well. The Python SDK's messages.parse() takes a Pydantic model and returns parsed_output; in TypeScript, zodOutputFormat() with messages.parse() does the same for Zod:
1import Anthropic from "@anthropic-ai/sdk";2import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";3import { z } from "zod";4 5const Invoice = z.object({6 vendor: z.string(),7 total_cents: z.number().int(),8 currency: z.string(),9 due_date: z.string().nullable(),10});11 12const client = new Anthropic();13const msg = await client.messages.parse({14 model: "<model>",15 max_tokens: 1024,16 messages: [{ role: "user", content: invoiceText }],17 output_config: { format: zodOutputFormat(Invoice) },18});19const invoice = msg.parsed_output;Quick reference
- Check helper names and import paths against the SDK version you use; these helpers have moved between releases.
- Claude's TypeScript
jsonSchemaOutputFormat()(without Zod) does not validate the response, so it will not catch constraints the API ignores. - Claude structured outputs are available on recent Claude models; on Amazon Bedrock, availability depends on the integration.
Remember this
OpenAI uses text.format with strict: true; Claude uses output_config.format; both SDKs parse Pydantic and Zod models for you.
The fine print: supported schemas and exceptions
Unsupported features fail loudly with a 400 error, which is the better failure. The quieter ones are the documented exceptions where a successful response does not match your schema:
- Refusals. If the model refuses for safety reasons, the refusal takes precedence over the schema. OpenAI adds a
refusalfield; Claude returnsstop_reason: "refusal", still with a 200 and billed tokens. - Truncation. If the response hits the output token limit, it is incomplete and will not parse. OpenAI marks the response
status: "incomplete"with reasonmax_output_tokens; Claude returnsstop_reason: "max_tokens". - Details. Claude does not guarantee the capitalisation of string
enumandconstvalues (compare case-insensitively), and puts required properties before optional ones.
So the parsing code always checks the stop reason or status first, and validates numeric ranges, string lengths, and cross-field rules in code, because the provider's subset cannot express them.
| Area | Supported | Not supported |
|---|---|---|
| Types | object, array, string, integer, number, boolean, null | Recursive schemas |
| Composition | enum (simple values), const, anyOf, allOf (limited), $ref and definitions | External $ref, complex types inside enums |
| Objects | required, default, additionalProperties: false | additionalProperties other than false |
| Strings | Formats such as date-time, email, uri, uuid; partial regex | minLength, maxLength, regex lookaround |
| Numbers and arrays | minItems of 0 or 1 | minimum, maximum, multipleOf; other array limits |
Quick reference
- Size
max_tokensfor the largest valid object plus headroom; a truncated JSON object is the most common silent failure. - Keep a single place that turns a response into a typed object or a typed error; never
json.loadsinline. - Log refusals and truncations separately from validation failures; they have different fixes.
Remember this
Check refusal and truncation before parsing, and validate in code everything the provider's schema subset cannot express.
Instructor: validation and retries across providers
Instructor wraps provider clients so that a call returns an instance of your Pydantic model, and automatically re-asks the model when validation fails. Its current recommended entry point is a provider-agnostic client:
1import instructor2from pydantic import BaseModel, field_validator3 4class Invoice(BaseModel):5 vendor: str6 total_cents: int7 currency: str8 9 @field_validator("total_cents")10 @classmethod11 def positive(cls, v: int) -> int:12 if v <= 0:13 raise ValueError("total must be positive")14 return v15 16client = instructor.from_provider("openai/gpt-4o-mini")17invoice = client.create(18 response_model=Invoice,19 messages=[{"role": "user", "content": invoice_text}],20 max_retries=3,21)When validation fails, here because a validator rejects a non-positive total, Instructor sends the validation error back to the model and tries again, up to max_retries. That covers rules no provider schema subset supports. The trade-off is cost and latency: each retry is another full model call, and a model that cannot satisfy a rule will burn every retry before failing. Instructor also has a TypeScript version built on Zod.
Quick reference
- Combine both: use native structured outputs for the shape where available, and Instructor-style validators for business rules.
- Keep
max_retriessmall and log every retry; a high retry rate means the schema or prompt is asking for something the input does not support. - Validators should raise clear messages; the message is what the model sees when it tries again.
Remember this
Instructor turns Pydantic validators into automatic re-asks; it covers rules constrained decoding cannot, at the price of extra calls.
Hands-on: extract invoices three ways
Use ten real or realistic invoice texts, including two that lack a due date and one that is not an invoice at all.
- Ask for JSON in the prompt only, and count parse failures and missing fields.
- Switch to native structured outputs with the
Invoicemodel (OpenAIresponses.parseor Claudemessages.parse). Count failures again. - Add a validator for a rule the schema cannot express (total positive, currency in a list), via Pydantic or Instructor with
max_retries=2. - Set
max_tokensvery low on one request and confirm your code detects truncation instead of crashing. - For the non-invoice text, see what the model puts in required fields.
Pass criterion: zero unhandled parse errors, truncation detected by stop reason, the business rule enforced in code, and a decision about how the schema should let the model say "this is not an invoice".
Quick reference
- Step 5 usually shows invented values; add a nullable field or a discriminated union for "not applicable".
- Record latency for the first request with each schema to see grammar compilation.
- Keep the ten inputs as a regression set for future model changes.
Remember this
Measure all three levels on real inputs; the failures that remain are the ones your schema design must handle.
Key takeaway
Reliable structured output from an LLM comes in layers. JSON mode only promises parseable JSON. Native structured outputs, text.format with strict: true on OpenAI and output_config.format on Claude, compile your JSON Schema into a grammar so decoding cannot leave the schema, with SDK helpers that take Pydantic models and Zod schemas and return parsed objects. Validation libraries such as Instructor add rules no schema subset can express and re-ask the model on failure.
The fine print matters: only a subset of JSON Schema is supported, refusals and token-limit truncation can still produce non-matching output, and a schema-valid object can be confidently wrong. Check the stop reason before parsing, validate meaning in code, and give the schema a way to say "not present". For the same discipline applied to agent tools, see Claude Agent SDK Quickstart.
Practice (45 min): run the five-step comparison above on your own data.
References
- OpenAI docs: Structured Outputs — checked Oct 17, 2026
- Claude docs: Structured outputs — checked Oct 17, 2026
- Instructor documentation — checked Oct 17, 2026
- Pydantic documentation: Validators — checked Oct 17, 2026
Shoaib Hossain
Author, Core Concept LearningRelated Articles
Explore this topic