Most LLM features end in code, not in a chat window. An extractor fills a database row, a router picks a queue, an agent calls a function. Every one of those needs output a program can parse, and "please answer in JSON" is a request, not a guarantee.
Two years ago the standard answer was a prompt, a regex to strip the markdown fences, json.loads in a try block, and a retry. Today every major API and every serious open-source inference server can constrain generation to a schema: the model is not allowed to produce a token that would break it. That changes what you need to test, but it does not remove the need to validate.
This post is a cheatsheet for that stack: the vocabulary, the JSON Schema subset that actually works, Pydantic patterns, how constrained decoding works, what research says about quality, and the failure modes that survive a "guaranteed" schema.
1. The vocabulary
| Term | Meaning | Why it matters |
|---|---|---|
| Prompted JSON | Asking for JSON in the instructions, maybe with an example | No guarantee. Works most of the time, fails on the long tail: fences, trailing commas, missing fields. |
| JSON mode | The API guarantees syntactically valid JSON, but not your schema | Parses, yet can still have wrong keys, wrong types or missing fields. OpenAI's docs recommend structured outputs over JSON mode whenever possible. |
| Structured outputs | The API guarantees output that conforms to a supplied JSON Schema | Removes parse and shape errors. Content can still be wrong. |
| Strict tool use / strict function calling | The same guarantee applied to tool-call arguments | What agents need: the arguments your function receives match its signature. |
| Constrained decoding / guided generation | At each step, tokens that would violate a grammar are masked out before sampling | The mechanism behind structured outputs, in hosted APIs and in open-source servers. |
| Grammar | A formal description of valid outputs: a regex, a context-free grammar (CFG), or a JSON Schema compiled to one | JSON Schema is the common interface. Regex and CFGs cover formats that are not JSON. |
| GBNF | llama.cpp's grammar format (a BNF variant) | Lets local models emit any format you can describe, not only JSON. |
| Validation | Checking a parsed object against types and business rules after generation | Still required: a schema says "integer", validation says "between 1 and 5 and matches the order's currency". |
| Refusal | The model declines for safety reasons instead of filling the schema | OpenAI returns it in a separate refusal field. Your code needs a branch for it. |
The progression matters: prompted JSON gives you probably parseable, JSON mode gives you parseable, structured outputs give you parseable and correctly shaped. None of them give you correct.
2. Where each option stands (May 2026)
| Platform | Mechanism | Since | Notes |
|---|---|---|---|
| OpenAI | response_format with type: "json_schema" and strict: true; strict: true on function definitions; Pydantic helpers in the SDK |
August 2024 | At launch, OpenAI reported gpt-4o-2024-08-06 with structured outputs at 100% on its complex schema eval, vs. under 40% for gpt-4-0613 without it. |
| Anthropic (Claude) | JSON outputs via output_config.format, strict tool use via strict: true on a tool |
Beta November 2025, generally available January 2026 | At GA the parameter moved from output_format to output_config.format and no beta header is needed. |
| Google Gemini | response_mime_type="application/json" plus a response schema |
Broad JSON Schema support announced November 2025 | Adds anyOf, $ref, minimum/maximum, additionalProperties, prefixItems; output keeps the schema's key order on Gemini 2.5 and later. |
| vLLM | structured_outputs request parameter (json, regex, choice, grammar), or OpenAI-style response_format |
Renamed from guided_* in v0.11.0 (October 2025); old names removed in v0.12.0 (December 2025) |
Backends: xgrammar, guidance, outlines, or auto. |
| llama.cpp | GBNF grammars (--grammar, --grammar-file); the server converts JSON Schema to GBNF |
Grammar sampling since July 2023 | Unsupported keywords are skipped silently, so test what is actually enforced. |
| Ollama | JSON Schema in the format parameter |
v0.5.0, December 2024 | The announcement uses Pydantic's model_json_schema() to produce the schema. |
If you are upgrading old code: the guided_json field in vLLM and the output_format field in Claude's beta are both gone. Check before you copy a snippet from an old issue thread.
3. The JSON Schema subset that actually works
Every provider supports a subset of JSON Schema, and the subsets differ. Design for the intersection unless you are locked to one stack.
| Pattern | Portable? | Notes |
|---|---|---|
type: object at the root |
Yes | OpenAI requires the root to be an object, not anyOf. |
string, number, integer, boolean, array, object |
Yes | The core. |
enum of strings |
Yes | The best tool for classification. Keep labels short and distinct. |
anyOf |
Mostly | Fine for nullable fields and small unions. Deeply nested unions get slow or rejected. |
Internal $ref / $defs |
Mostly | Pydantic emits them for nested models and enums. External $ref is not supported by Claude. |
| Recursive schemas | No | Claude rejects them; others limit nesting depth. Flatten trees to a list with parent IDs. |
required on every field |
Required by OpenAI strict mode | "Optional" means nullable: "type": ["string", "null"], still listed in required. |
additionalProperties: false |
Required by OpenAI and Claude | Claude does not accept any other value. |
minimum, maximum, multipleOf |
No | Supported by OpenAI and Gemini, not by Claude. Validate after. |
minLength, maxLength |
No | Not supported by Claude; documented as not enforced by Gemini. Validate after. |
pattern, format (date, email, uuid) |
Partly | OpenAI and Claude support common formats. Do not rely on complex regexes being enforced. |
OpenAI also documents hard size limits: up to 5,000 object properties, 10 levels of nesting, and 1,000 enum values per schema. You will hit nesting limits long before property limits.
Rules of thumb:
- Make every field required, and use null for "not present". It is portable, and it forces the model to make an explicit choice instead of silently skipping a field.
- Use enums for anything categorical. A free-text
statusfield will eventually contain "Pending (awaiting reply)". - Add
descriptionto fields. Descriptions go to the model with the schema. They are the field-level prompt. - Keep constraints you cannot enforce in validation. If a provider ignores
maximum, the schema will still accept 9,999 stars on a 1-5 rating. - Put reasoning before answers. Keys are generated in order. A
reasoningfield beforelabelgives the model room to think; alabelbeforereasoningproduces a justification for a decision already made.
4. Pydantic as the source of truth
Write the schema once as a Pydantic model and derive everything else from it: the JSON Schema you send, the parsing, the validation and your type hints. Pydantic v2 (2.13 is current at the time of writing) has the three methods you need.
# /// script
# dependencies = ["pydantic>=2"]
# ///
import json
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
class Priority(str, Enum):
low = "low"
medium = "medium"
high = "high"
class Ticket(BaseModel):
model_config = ConfigDict(extra="forbid") # emits additionalProperties: false
reasoning: str = Field(description="One or two sentences on how priority was chosen")
title: str = Field(description="One-line summary of the issue")
priority: Priority
customer_email: str | None = Field(description="Email if present in the text, else null")
tags: list[str] = Field(description="Zero to five lowercase tags")
@field_validator("tags")
@classmethod
def at_most_five(cls, v: list[str]) -> list[str]:
if len(v) > 5:
raise ValueError("at most 5 tags")
return [t.lower() for t in v]
schema = Ticket.model_json_schema() # send this to the model
print(json.dumps(schema["required"])) # every field is required, email is nullable
raw = '{"reasoning": "Payment issue.", "title": "Refund not received", "priority": "urgent", "customer_email": null, "tags": ["billing"]}'
try:
Ticket.model_validate_json(raw) # parse and validate in one step
except ValidationError as e:
print(e.errors()[0]["loc"], e.errors()[0]["type"]) # ('priority',) enum
| Pydantic API | Use |
|---|---|
Model.model_json_schema() |
Produce the JSON Schema for the request. Inspect it once: $defs, anyOf for nullable fields, titles. |
Model.model_validate_json(text) |
Parse and validate the raw response in one step. Faster and stricter than json.loads plus model_validate. |
TypeAdapter(list[Model]) |
Schemas and validation for types that are not a model, such as a top-level list. Wrap lists in an object for the request anyway, since roots must be objects. |
ConfigDict(extra="forbid") |
Rejects unknown keys and emits additionalProperties: false. |
field_validator, model_validator |
Business rules the schema cannot express: ranges, cross-field consistency, lookups. |
Provider SDKs accept the model directly. With OpenAI's Python SDK:
from openai import OpenAI
client = OpenAI()
response = client.responses.parse(
model="gpt-4o-2024-08-06",
input=[
{"role": "system", "content": "Extract a support ticket from the email."},
{"role": "user", "content": email_text},
],
text_format=Ticket,
)
ticket = response.output_parsed # a Ticket instance, or None on refusal
With Claude, send the schema in output_config:
import anthropic
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": f"Extract a support ticket:\n\n{email_text}"}],
output_config={"format": {"type": "json_schema", "schema": Ticket.model_json_schema()}},
)
ticket = Ticket.model_validate_json(message.content[0].text)
Either way, validate with your own model at the end. The provider guarantees the shape it understood, and your validators check the rules it did not.
5. How constrained decoding works
A language model produces a probability for every token in its vocabulary at each step. Constrained decoding inserts one step before sampling:
- Compile the schema or regex into an automaton: a finite-state machine for regular languages, a pushdown automaton for context-free grammars such as nested JSON.
- At each step, compute which vocabulary tokens are valid continuations from the current state.
- Set the probability of all other tokens to zero (mask the logits), then sample as usual.
- Advance the automaton with the chosen token.
The hard part is speed. Vocabularies have 100,000 or more tokens, and a token can span several grammar symbols. Each library attacks that differently:
| Library | Idea | Reference |
|---|---|---|
| Outlines | Precompute an index from FSM states to allowed tokens, so each step is a lookup | Willard and Louf, Efficient Guided Generation for Large Language Models (2023) |
| XGrammar | Split tokens into those that can be checked ahead of time and those that need runtime context; reports up to 100x speedups and near-zero overhead in serving | Dong et al. (2024), MLSys 2025 |
| llguidance | Fast Earley-style parsing with lazy token masks; integrated into llama.cpp, SGLang and vLLM in early 2025 | guidance-ai/llguidance; its README states it shipped in OpenAI's structured outputs in May 2025 |
| GBNF (llama.cpp) | Grammar-based sampling in the inference loop | llama.cpp grammars README |
Practical consequences:
- The first request with a new schema can be slower. Grammars are compiled and cached. Claude's GA notes mention improved grammar compilation latency; reuse schemas instead of generating a new one per request.
- Constraints change the distribution. Masking forces the model down paths it may have assigned low probability. A schema that fights the model's natural phrasing (odd key names, unexpected order) costs quality.
- Constraints do not add knowledge. A required
invoice_numberwill be filled whether or not the document contains one. Give the model a legal way to say "not found": a nullable field or an explicit"unknown"enum value.
6. Does constraining hurt quality?
The research is mixed, and the details matter.
| Study | Finding | Caveat |
|---|---|---|
| Tam et al., Let Me Speak Freely? (2024) | Reported a significant decline in reasoning performance under format restrictions, worse with stricter formats | Prompts and answer parsing differed between conditions. |
| .txt (Will Kurt), Say What You Mean (November 2024) | Re-ran the evaluations with matched prompts and proper structured generation; structured output matched or beat unstructured (GSM8K 0.78 vs. 0.77 with Llama-3-8B-Instruct) | Written by a company that sells structured generation. The methodology is public. |
| Geng et al., JSONSchemaBench (January 2025) | 10,000 real-world schemas across six frameworks; schema coverage differed about 2x between the best and worst; constrained decoding could speed up generation by up to 50% and improved downstream accuracy by up to 4%, including on GSM8K | Results vary by framework and schema complexity. |
The reconciling view: constraints hurt when they leave the model no room to reason or when the schema fights the task. They help or are neutral when the schema includes a reasoning field before the answer and the prompt matches the format. Test on your own task: run the same eval with and without the schema, and compare accuracy, not only parse rate.
7. Failure modes that survive a schema
| Failure | Symptom | Fix |
|---|---|---|
| Truncation | Output cut off mid-object; parse fails despite "guaranteed" JSON | Check finish_reason == "length" (Chat Completions) or status == "incomplete" (Responses API) before parsing. Raise max_tokens, shrink the output. |
| Refusal | Empty parsed object, or refusal set |
Handle it as a separate outcome, not a parse error. Log and route to a human. |
| Hallucinated required fields | Plausible values for data that is not in the input | Nullable fields, an "unknown" option, and an instruction to use them. Evaluate with inputs that lack the field. |
| Enum collapse | Everything gets the first or most generic label | Clear descriptions per label, an other label, and a confusion matrix in your eval. |
| Silent schema downgrade | A keyword is ignored by the provider or converter; the constraint is not enforced | Re-validate with Pydantic; test edge values (11 on a 1-10 scale). |
| Schema drift | Code and prompt use different versions of the model | Generate the schema from the Pydantic model at runtime; version it with the prompt. |
| Long arrays and deep nesting | Latency spikes, rejected schemas, or repetitive lists | Cap list sizes in the prompt and validation, flatten structures, split into multiple calls. |
| Unsupported provider features | Works on one API, 400 error on another | Stick to the portable subset in section 3; keep one adapter per provider. |
A provider-independent wrapper for the whole loop is short. It also works with prompted JSON on models without native support:
from typing import Callable, TypeVar
from pydantic import BaseModel, ValidationError
T = TypeVar("T", bound=BaseModel)
def generate_structured(call: Callable[[str], tuple[str, str]], model: type[T], prompt: str, retries: int = 2) -> T:
"""call(prompt) returns (text, stop_reason). Retries once per validation error, with feedback."""
attempt_prompt = prompt
for attempt in range(retries + 1):
text, stop = call(attempt_prompt)
if stop in ("length", "max_tokens"):
raise RuntimeError("Output truncated: raise the token limit or shrink the schema")
try:
return model.model_validate_json(text)
except ValidationError as e:
if attempt == retries:
raise
attempt_prompt = f"{prompt}\n\nYour previous answer was invalid:\n{e}\nReturn corrected JSON only."
raise AssertionError("unreachable")
With native structured outputs the retry branch should almost never fire on shape errors, but it still catches your own validator rules. Count how often it fires: a rising retry rate is an early warning that a prompt or model change broke something.
8. Testing structured output
Treat the schema as an API contract and the extraction as a model behavior. Test both.
| Test | What it checks | How |
|---|---|---|
| Schema snapshot | The schema sent to the model did not change by accident | Snapshot model_json_schema() in a unit test. |
| Provider acceptance | Each target provider accepts the schema | One cheap live call per provider in CI, or on schema change. |
| Validity rate | Share of responses that parse and validate | Should be near 100% with native structured outputs. Anything less is a bug to investigate. |
| Field-level accuracy | Each field is correct against labeled examples | Per-field precision and recall, exact match for IDs and amounts. |
| Null handling | The model returns null when the data is absent | Include inputs with missing fields in the test set. |
| Edge inputs | Very long input, other languages, adversarial text ("ignore the schema") | A small fixed set in the regression suite. |
The point is to separate the two failure classes. A validity failure is a plumbing problem: schema, provider, truncation. An accuracy failure is a model or prompt problem. Structured outputs largely solved the first; the second is where your eval effort belongs.
Why this matters
- Shape is solved; content is not. Native structured outputs remove parse errors, so the remaining errors are about correctness, which is where validation and evals must focus.
- The portable subset is small. Required-plus-nullable fields, enums,
additionalProperties: falseand shallow nesting work almost everywhere; range and length constraints belong in validation. - One Pydantic model, many uses. Schema, parser, validator and type hints from one definition prevents drift between prompt and code.
- Schema design is prompt design. Field order, names, descriptions and an explicit "not found" option change answers, not just formatting.
- Measure the trade-off on your task. Research on format restrictions disagrees; a with-and-without eval on your data settles it for your case.
Final Thoughts
Structured outputs moved JSON from "hope and retry" to a guarantee enforced at the token level. That is a real improvement, and it makes LLMs much easier to wire into ordinary software.
But a guarantee about shape tempts teams to stop checking content. Keep the Pydantic validators, keep the null options, keep an eval with labeled examples. The schema makes the output parseable; only your tests make it trustworthy.
Sources
- OpenAI, Introducing Structured Outputs in the API (August 2024): launch,
strict: true, 100% vs. under 40% schema-following eval. - OpenAI, Structured Outputs guide: supported schema subset, required and nullable fields,
additionalProperties: false, size limits, JSON mode vs. structured outputs, refusals. - OpenAI, openai-python changelog:
parsehelpers, moved out of beta in v1.92.0 (June 2025). - Anthropic, Claude API release notes (November 2025, January 2026): structured outputs beta and general availability, move to
output_config.format. - Anthropic, Structured outputs: JSON outputs, strict tool use, unsupported schema features.
- Google, Improving Structured Outputs in the Gemini API (November 2025): JSON Schema keyword support, Pydantic and Zod, key ordering.
- Google, Structured output, Gemini API docs: unenforced keywords, schema size limits.
- vLLM, Structured Outputs and release notes for v0.11.0 (October 2025) and v0.12.0 (December 2025): parameters, backends, removal of
guided_*. - llama.cpp, GBNF grammars README: grammar syntax, JSON Schema conversion, unsupported keywords.
- Ollama, Structured outputs (December 2024):
formatparameter with Pydantic schemas. - Pydantic, pydantic on PyPI (2.13.4, May 2026) and JSON Schema docs.
- Willard and Louf, Efficient Guided Generation for Large Language Models (July 2023): FSM-based guided generation (Outlines).
- Dong et al., XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models (November 2024): token splitting, up to 100x speedup.
- guidance-ai, llguidance README: integrations and OpenAI adoption note (May 2025).
- Tam et al., Let Me Speak Freely? A Study on the Impact of Format Restrictions on Performance of Large Language Models (August 2024).
- Will Kurt (.txt), Say What You Mean: A Response to 'Let Me Speak Freely' (November 2024).
- Geng et al., Generating Structured Outputs from Language Models: Benchmark and Studies (JSONSchemaBench) (January 2025).