Structured outputs and validation.

Provider-native structured output removes parse errors, not wrong answers. This guide covers the schema rules for each major provider, a two-layer validation design, and the tests that catch what the schema cannot.

Engineering guide9 min readUpdated 19 October 2026

Short answer

LLM structured output is a model response constrained to a JSON Schema, usually by constrained decoding on the provider side. It reliably removes parse errors, but it does not make the values correct. Production systems need a second validation layer for business rules, cross-field arithmetic and grounding in the source, plus a bounded repair loop and explicit handling of refusals and truncated responses.

  • Constrained decoding guarantees schema shape in most cases, never semantic correctness.
  • OpenAI, Anthropic and Google each support a different JSON Schema subset; design to the strictest.
  • Required fields with no honest null option invite fabricated values.
  • Check stop or status reasons for refusal and truncation before parsing.
  • Cap repair at one model retry, then route to a person.

What structured output actually guarantees

Structured output guarantees shape, not truth. With provider-native constrained decoding, the model can only emit tokens that keep the response inside your schema, so missing keys, wrong types and invented enum values largely disappear. What remains is the harder class of error: a well-formed invoice total that is wrong, or a required field filled with something plausible because the document did not contain it.

There are three ways to get JSON out of a model, and they fail differently:

Approach What it guarantees What still goes wrong Use it when
Constrained decoding (strict schema) Valid JSON matching a schema subset, in most cases Wrong values, fabricated required fields, refusals, truncation Any production extraction or classification
JSON mode Syntactically valid JSON Missing keys, wrong types, extra keys Older models without schema support
Free text, parsed afterwards Nothing Everything above plus parse failures Prototypes only

In the systems we run, every model response that feeds another system goes through constrained decoding where the model supports it, and then through our own validators regardless.

How the provider schema rules differ

Each provider supports a different subset of JSON Schema, and a schema that works on one can return a 400 error on another. Design to the strictest subset you might need, because model selection changes over a system’s life. The rules below are as documented on 19 October 2026.

Rule OpenAI (strict: true) Anthropic (output_config.format, strict tools) Google Gemini (response schema)
Optional fields Not allowed: every field must be in required; use a null type instead Allowed, but at most 24 optional parameters per request Allowed via required list
additionalProperties Must be false on every object Must be false Boolean or schema
Numeric bounds minimum, maximum, multipleOf supported Not supported minimum, maximum supported
String length pattern and format supported No minLength or maxLength; simple regex only, no lookahead enum and format (date, time, date-time)
Array length minItems, maxItems supported minItems of 0 or 1 only minItems, maxItems, prefixItems
Recursion Supported Not supported Supported (documented example)
Size limits 5,000 properties, 10 levels of nesting, 1,000 enum values 20 strict tools, 16 union-typed parameters per request “Very large or deeply nested schemas may be rejected”

Two provider details catch teams out. Anthropic emits required properties first, then optional ones, regardless of schema order, and does not guarantee the capitalisation of enum values: a response can contain “Conversation Topic 3” when the enum says “Conversation topic 3”, with no error. Anthropic also compiles each schema into a grammar on first use and caches it for 24 hours from last use, so the first request after a schema change is slower, and changing the format invalidates the prompt cache for that thread.

Library defaults matter too. Pydantic 2 renders Decimal as a number-or-string union whose string branch uses a lookahead regex, and every Optional field becomes a union with null. Both count against Anthropic’s union limit, and the lookahead is outside its supported regex features. Send floats or integer cents on the wire and convert after validation.

Design rule: validate in two layers

Layer one checks shape; layer two checks meaning. Generate the schema the provider sees and the type your code validates from the same source, as OpenAI’s documentation recommends, so they cannot drift apart. Put every constraint the provider cannot enforce (numeric ranges on Claude, string lengths, cross-field rules) into layer two.

Illustrative example: a Wellington property manager extracting fields from supplier invoices before coding them in Xero.

from datetime import date
from typing import Literal, Optional
from pydantic import BaseModel, Field, model_validator

class LineItem(BaseModel):
    description: str
    amount_excl_gst: float

class SupplierInvoice(BaseModel):
    is_invoice: bool = Field(description="False if this document is not a supplier invoice.")
    supplier_name: Optional[str]
    gst_number: Optional[str] = Field(description="Digits as printed, or null if absent.")
    invoice_number: Optional[str]
    invoice_date: Optional[date]
    subtotal: Optional[float]
    gst: Optional[float]
    total: Optional[float]
    currency: Literal["NZD", "AUD", "USD", "OTHER"]
    line_items: list[LineItem]

    @model_validator(mode="after")
    def totals_reconcile(self):
        vals = (self.subtotal, self.gst, self.total)
        if None not in vals and abs(vals[0] + vals[1] - vals[2]) > 0.01:
            raise ValueError("subtotal plus gst does not equal total")
        return self

def semantic_problems(inv: SupplierInvoice, source_text: str) -> list[str]:
    problems = []
    if not inv.is_invoice:
        return ["not an invoice"]
    if inv.invoice_date and inv.invoice_date > date.today():
        problems.append("invoice_date is in the future")
    digits = "".join(c for c in (inv.gst_number or "") if c.isdigit())
    if inv.gst_number and len(digits) not in (8, 9):
        problems.append("gst_number is not 8 or 9 digits")
    for name in ("invoice_number", "gst_number"):
        value = getattr(inv, name)
        if value and value not in source_text:
            problems.append(f"{name} does not appear in the source")
    if inv.total is not None and f"{inv.total:,.2f}" not in source_text \
            and f"{inv.total:.2f}" not in source_text:
        problems.append("total does not appear in the source")
    return problems

The grounding checks are the important part. An identifier or amount that cannot be found in the source text is the signature of a fabricated value, and string matching catches it for almost no cost. For fields that are derived rather than copied (a category, a summary), grounding needs a different check, usually a cited span that a reviewer can see.

Handle refusals and truncation before parsing

Read the stop or status reason first, then parse. Both documented exceptions to the schema guarantee arrive as normal-looking responses:

  • Anthropic returns stop_reason: "refusal" with HTTP 200 (billed, and the body may not match your schema) and stop_reason: "max_tokens" when output was cut off.
  • OpenAI returns a refusal content item, or status: "incomplete" with incomplete_details.reason set to max_output_tokens.

A parser that tries the JSON first will report these as validation errors and trigger pointless retries. Anthropic also notes that a schema property asking for step-by-step reasoning can itself cause a refusal; ask for a short explanation field instead.

A bounded repair and retry policy

Classify the failure, then act once. Unbounded “retry until valid” loops are how a document that genuinely lacks a field costs ten calls and still produces a fabricated value.

from pydantic import ValidationError   # call_model and Outcome wrap your SDK and review queue

MAX_MODEL_ATTEMPTS = 2

def extract(source_text: str) -> Outcome:
    feedback = None
    for attempt in range(MAX_MODEL_ATTEMPTS):
        resp = call_model(source_text, feedback=feedback)   # strict schema on
        if resp.stop_reason == "refusal":
            return Outcome.review("refusal", resp)          # never resend unchanged
        if resp.stop_reason == "max_tokens":
            return Outcome.review("truncated", resp)        # raise limit or split input
        try:
            inv = SupplierInvoice.model_validate_json(resp.text)
        except ValidationError as err:
            feedback = f"Schema errors: {err.errors()}"
            continue
        problems = semantic_problems(inv, source_text)
        if not problems:
            return Outcome.ok(inv)
        feedback = "Fix only these problems, using the source: " + "; ".join(problems)
    return Outcome.review("validation_failed", feedback)

Transport errors (timeouts, 429, 5xx) belong to a separate layer with backoff, covered in our guide to agent retry and failure recovery. The repair prompt should list the specific failures and include the source again; asking the model to “try again” without the errors usually reproduces the same answer.

Test the malformed cases before production does

A malformed-output suite runs your parser and validators against recorded responses, so it is fast, deterministic and free. Keep a separate live evaluation set for model quality, and generate edge cases with the approach in testing AI with synthetic data.

Case How to produce it Expected behaviour
Truncated response Set the output limit very low on a long invoice Routed to review as truncated; nothing written downstream
Refusal Recorded response with a refusal stop reason No retry; logged with the request ID
Enum casing drift Fixture returning “nzd” or “Paid” for “PAID” Normalised case-insensitively, or rejected; never silently stored
Empty but valid All strings empty, line_items empty Rejected by the semantic layer
Invented required value Invoice image with no GST number printed gst_number is null; a non-null value fails grounding
Unrelated document A delivery docket or a photo is_invoice false; no fields used
Hostile content in a field Description containing HTML or a Markdown image link Escaped at render time; no outbound fetch

The last row matters because extracted text flows into other systems. The OWASP Top 10 for LLM Applications 2026 lists Improper Output Handling as LLM10:2026: model output passed downstream without validation or encoding can lead to XSS, SSRF or worse, and renderers that auto-fetch Markdown images can exfiltrate context. Treat every field as untrusted input: parameterised queries, context-specific encoding, and no automatic fetching of URLs from model output.

Where structured outputs go wrong

The common failures are design failures, not model failures:

  • Schema-valid nonsense. Teams stop checking once parse errors disappear. Track semantic failure rates per field in quality monitoring; a field whose failure rate climbs after a model change is your early warning.
  • No honest null. A required non-nullable field forces a guess. OpenAI’s own guidance warns that unrelated input plus a strict schema “can result in hallucinations”.
  • Schema sprawl. Large schemas with many optional unions hit complexity limits (“Schema is too complex for compilation” on Anthropic) and degrade accuracy. Split extraction into two smaller calls rather than one giant form.
  • Silent schema changes. A renamed enum value breaks downstream mappings and invalidates caches. Version schemas with the prompt and run the eval set on every change.
  • Structured output used where tools fit better. If the model needs to act, not answer, use tool calling with strict argument schemas instead.

For document-heavy workloads, our AI document processing work applies these rules with a human review step for anything the validators reject, and the same contracts underpin how we wire outputs into finance and CRM systems in AI integration. The wider pattern sits inside reliable agent design.

  • Constrained decoding (strict schema) is on for every model call that feeds another system
  • The wire schema and the validation type are generated from one definition
  • Fields that may be absent are required but nullable
  • Stop or status reasons for refusal and truncation are checked before parsing
  • A semantic layer checks arithmetic, dates, formats and cross-field rules
  • Copied identifiers and amounts are verified to appear in the source
  • Enum values are compared case-insensitively or rejected
  • Repair is capped at one extra model attempt, then a review queue
  • Recorded fixtures cover truncation, refusal, empty, invented and hostile outputs
  • Every field is encoded for its sink: SQL parameters, HTML escaping, no auto-fetched URLs

Sources

  1. Structured model outputs, OpenAI API documentation, accessed 19 October 2026
  2. Structured outputs, Claude API documentation (Anthropic), accessed 19 October 2026
  3. Strict tool use, Claude API documentation (Anthropic), accessed 19 October 2026
  4. Structured output, Gemini API documentation (Google), accessed 19 October 2026
  5. OWASP GenAI LLM Top 10 2026, OWASP GenAI Security Project, August 2026
  6. Pydantic documentation, Pydantic, accessed 19 October 2026

Written by the Levy Street team in Wellington. Last updated 19 October 2026.

Questions people ask

What is the difference between JSON mode and structured outputs?

JSON mode only promises syntactically valid JSON. Structured outputs (OpenAI’s strict json_schema format, Anthropic’s output_config.format, Gemini’s response schema) constrain generation to a schema you supply, so required keys, types and enums are enforced while tokens are generated. OpenAI’s documentation recommends structured outputs over JSON mode wherever the model supports it. Neither checks that the values are true.

Do I still need validation if the provider guarantees the schema?

Yes. The guarantee covers shape, not meaning, and it has documented exceptions: refusals, responses cut off at the token limit, and on Claude, enum values that differ only in capitalisation. Run the response through your own typed model, then through business-rule and grounding checks. Google’s Gemini documentation says the same thing: always validate values in your application.

Should optional fields be optional in the schema?

Make every field required but nullable when the source might not contain it. OpenAI strict mode requires all fields anyway. On Claude, optional parameters count against a limit of 24 per request and nullable ones against a limit of 16 union-typed parameters, so split wide forms into smaller calls. A nullable field gives the model an honest way to say a value is missing, rather than inventing an invoice number.

How many times should I retry a failed extraction?

Once, with the validator’s error messages and the original source attached. If the second attempt fails, send the item to a review queue with both attempts logged. Never retry a refusal with the same prompt, and handle truncation by raising the output limit or splitting the input rather than repeating the identical call.

Can I combine structured outputs with tool calling?

Yes. Use strict tool schemas for the arguments the model passes to your functions, and a structured output format for the final answer. On Claude both features share one schema budget per request: 20 strict tools, 24 optional parameters and 16 union-typed parameters across all strict schemas, as documented on 19 October 2026.

Building something that has to hold up?

We design, evaluate and run AI systems in production. Tell us what you are building and where it hurts.