Skip to content

LLM Structured Outputs: JSON Schema, Pydantic, Zod, Instructor

By Shoaib HossainAugust 1, 202613 min readUpdated October 17, 2026

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 of structured output: valid JSON, schema-constrained decoding, and validate-and-retry on top
Three levels of structured output: valid JSON, schema-constrained decoding, and validate-and-retry on top

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 name or description fields 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.

How constrained decoding works: the schema compiles to a grammar, and at each step only tokens that keep the output valid can be sampled
How constrained decoding works: the schema compiles to a grammar, and at each step only tokens that keep the output valid can be sampled

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_parsed

Claude (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 refusal field; Claude returns stop_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 reason max_output_tokens; Claude returns stop_reason: "max_tokens".
  • Details. Claude does not guarantee the capitalisation of string enum and const values (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.

Documented limits of Claude's structured outputs (OpenAI documents its own similar subset)
AreaSupportedNot supported
Typesobject, array, string, integer, number, boolean, nullRecursive schemas
Compositionenum (simple values), const, anyOf, allOf (limited), $ref and definitionsExternal $ref, complex types inside enums
Objectsrequired, default, additionalProperties: falseadditionalProperties other than false
StringsFormats such as date-time, email, uri, uuid; partial regexminLength, maxLength, regex lookaround
Numbers and arraysminItems of 0 or 1minimum, maximum, multipleOf; other array limits
Where schema-constrained output still fails, and what to do about each case
Where schema-constrained output still fails, and what to do about each case

Quick reference

  • Size max_tokens for 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.loads inline.
  • 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_retries small 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.

  1. Ask for JSON in the prompt only, and count parse failures and missing fields.
  2. Switch to native structured outputs with the Invoice model (OpenAI responses.parse or Claude messages.parse). Count failures again.
  3. Add a validator for a rule the schema cannot express (total positive, currency in a list), via Pydantic or Instructor with max_retries=2.
  4. Set max_tokens very low on one request and confirm your code detects truncation instead of crashing.
  5. 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.

Share:

References

  1. OpenAI docs: Structured Outputs — checked Oct 17, 2026
  2. Claude docs: Structured outputs — checked Oct 17, 2026
  3. Instructor documentation — checked Oct 17, 2026
  4. Pydantic documentation: Validators — checked Oct 17, 2026

Shoaib Hossain

Author, Core Concept Learning

Related Articles

Model APIs fail in ordinary ways: rate limits, overloaded capacity, server errors, dropped connections and calls that ru…

Read

The Claude Agent SDK gives your own programs the same agent loop that runs Claude Code: you call query() with a prompt a…

Read

"MCP or OpenAPI?" is a common question when connecting AI agents to tools, and it is slightly the wrong question. The Mo…

Read

Keep learning

Follow a structured path or browse all courses to go deeper.