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) andstop_reason: "max_tokens"when output was cut off. - OpenAI returns a
refusalcontent item, orstatus: "incomplete"withincomplete_details.reasonset tomax_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
- Structured model outputs, OpenAI API documentation, accessed 19 October 2026
- Structured outputs, Claude API documentation (Anthropic), accessed 19 October 2026
- Strict tool use, Claude API documentation (Anthropic), accessed 19 October 2026
- Structured output, Gemini API documentation (Google), accessed 19 October 2026
- OWASP GenAI LLM Top 10 2026, OWASP GenAI Security Project, August 2026
- Pydantic documentation, Pydantic, accessed 19 October 2026
Written by the Levy Street team in Wellington. Last updated 19 October 2026.