Valid JSON, Wrong Data: Where Structured Outputs Stop Working
In this article
Structured Outputs in the OpenAI Python SDK solved a genuine engineering headache: before native schema enforcement, extracting reliable JSON from an LLM required regex parsers, retry loops, and prompts that pleaded with the model to skip markdown fences. That problem is largely gone. What replaced it is quieter and more dangerous — a class of failure that passes every type check, never throws an exception, and only surfaces when something downstream depends on an extracted value actually being real.
The case study that grounds this is a payment-parsing pipeline built on gpt-4o with a Transaction Pydantic model requiring sender, amount, transaction_id, and transaction_date. Three weeks after enabling Structured Outputs, a reconciliation job began flagging 2–3% of weekly transaction volume as mismatched — not on amount or sender, but on date. Every mismatch traced back to a source message that contained no date at all, such as "Payment received from Chinedu, ₦45,000, ref TXN-82K91." Because transaction_date: date was marked required, the model could not return null. It filled the field anyway, almost always with the date the extraction job ran. The JSON was valid. The data was fabricated.
Schema Design: Nullable Fields and the Extraction-vs-Inference Boundary
The structural fix is making fields nullable — sender: str | None, transaction_date: date | None — so the model can represent absence without inventing a substitute. This also forces a design decision that Structured Outputs otherwise make invisible: whether a field is being extracted or inferred. A message saying "paid on Tuesday" paired with a schema demanding an ISO date is an inference request regardless of intent. Nullable fields return that decision to application code, where it can be handled deliberately — routing incomplete records to a request_missing_info() call keyed on transaction_id rather than silently accepting a hallucinated timestamp.
Note that gpt-4o-mini was evaluated for this pipeline and rejected: it was observed to merge amount and reference into a single field on messages with unusual formatting, which is why the pipeline stayed on the full gpt-4o model despite higher cost.
Evidence Fields and the Provenance Problem
Nullable fields address fabrication from missing data. They do not address the harder problem: a model returning a plausible-looking value it pattern-matched rather than read. With a standard chat response, reasoning is at least visible. Structured Outputs collapse directly to final form, skipping that audit trail entirely.
The remedy is an Extracted wrapper class pairing each value with an evidence field containing the exact source-text quote backing it:
class Extracted(BaseModel):
value: float | date | str | None
evidence: str | None = Field(description="exact quote backing this value, empty if not found")
Key implementation detail: evidence is declared before value in the class body. Because keys are generated in sequence, this forces the model to write down the supporting text before committing to an answer — a structural show-your-work constraint. A filled value paired with an empty or non-matching evidence field makes hallucination visible in the data itself rather than requiring re-inspection of the source document.
The cost is real: on a batch of a few hundred transaction messages, adding evidence fields pushed output tokens up by roughly a third, with a corresponding latency increase. For a five-digit zip code that trade is not justified; for a financial figure that triggers downstream action, it is.
Pydantic Validators as a Deterministic Correctness Layer
Schema shape and provenance evidence still leave a third failure mode unaddressed: values that are correctly typed and genuinely extracted, but semantically invalid — a negative payment amount, or a transaction_date set in the future. Enforcing these constraints in the prompt is the wrong tool; a language model is not a calculator. A Pydantic @model_validator runs the same logic every time, deterministically, with no API call involved:
@model_validator(mode="after")
def check_sane_values(self) -> "Transaction":
if self.amount is not None and self.amount <= 0:
raise ValueError(f"amount must be positive, got {self.amount}")
if self.transaction_date is not None and self.transaction_date > date.today():
raise ValueError(f"transaction_date {self.transaction_date} is in the future")
return self
When validation fails, the pipeline feeds the exact ValidationError string back to the model with the instruction to fix only the bad field, capped at MAX_RETRIES = 2. The hard cap matters: two consecutive failures almost always indicate a malformed source document rather than a prompt problem, and a third automated attempt burns API calls on a case a human reviewer resolves in seconds.
Failure Mode Comparison
| Failure Mode | Throws Error? | Caught by Schema? | Mitigation |
|---|---|---|---|
| Malformed JSON / broken types | Yes | Yes — native enforcement | Structured Outputs (solved) |
| Fabricated value for missing field | No | No | Nullable fields; route None explicitly |
| Pattern-matched value, no source support | No | No | Evidence field + mismatch detection |
| Semantically invalid value (negative amount, future date) | No | No | Pydantic @model_validator with retry loop |
The full approach — nullable types, evidence provenance, deterministic validators, capped retries — is not OpenAI-specific. Swapping to Anthropic tool use or a self-hosted stack with vLLM and Outlines leaves the Pydantic model unchanged; only the API call layer moves. That portability matters as teams weigh inference costs against output quality, a tradeoff explored in our coverage of pipeline architecture decisions in 2026.
Schema enforcement and data correctness are not the same property, and conflating them is a systems design error rather than a model limitation. Structured Outputs moved the reliability problem upstream from the parser to the extraction logic — which is an improvement, but also a shift that makes remaining failure modes less visible to teams that stop auditing once the JSON validates. A well-formed object is not a correctness guarantee. At 2–3% weekly volume, this pipeline learned that the expensive way.
Related Reading

Structured Output with Local LLMs: When Valid JSON Is Not Enough
Gemma 4's 4B model returns schema-valid JSON that still includes the wrong device. Here's the decomposition pattern that fixes it.
Building AI Agents in Python with Pydantic AI
Gemini Notebook's Expert Intelligence Unlocks 100,000+ Books
Google's Expert Intelligence feature lets Gemini Notebook pull from 100,000+ purchased Play Books titles, with per-user entitlement blocking shared access.