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 status field will eventually contain "Pending (awaiting reply)".
  • Add description to 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 reasoning field before label gives the model room to think; a label before reasoning produces 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:

  1. 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.
  2. At each step, compute which vocabulary tokens are valid continuations from the current state.
  3. Set the probability of all other tokens to zero (mask the logits), then sample as usual.
  4. 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_number will 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: false and 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