Reliable LLM Output Needs Validation, Not Just Valid JSON
A MachineLearningMastery tutorial argues valid JSON can hide wrong LLM answers, and shows how validation, retries and constrained decoding catch them.
Source: MachineLearningMasteryDevelopers who treat a successful JSON parse as the finish line are stopping too early, according to a new tutorial on MachineLearningMastery by Nahla Davies. The piece argues that reliable structured output from large language models depends on three layers beyond parsing — validation against a contract you own, retries that only fire when they can help, and constrained decoding — and it walks through a working Python example of each.
Why can a valid JSON response still be wrong?
The tutorial opens with a short invoice and a deceptively clean model response. Given four lines of text stating an invoice number, a bill-to name, a total of EUR 1,250.00 and a payment term, a model asked for the invoice id, currency, total in cents and the source line returns an object that looks correct. Every key is present, the types are right, the currency is in the allowed set, and the quoted line really does appear in the invoice.
The problem is the amount. The response reports 12,500 cents, which is 125 euros, for an invoice that says 1,250. According to the tutorial, no parser catches this and no schema catches it, because 12500 is a perfectly valid integer. The only thing that catches it is a comparison against the source text — and the application has to make that comparison itself.
What are the three layers of reliable structured output?
Davies frames the three terms in the title as three layers that do the catching. Validation means your code checks the returned object against a contract you own, then against the invoice it came from. Retries means a second attempt is allowed only when another attempt can actually fix what went wrong, with a hard stop at three. Constrained decoding means the generator can only emit tokens that keep the output inside your schema, so a whole class of shape failures never reaches validation.
The examples were run on Python 3.10.12 and Pydantic 2.14.1, and the tutorial notes the code only extracts fields and never writes to a real system. Structured output itself is described as standard now: OpenAI's Responses API accepts a JSON schema or a Pydantic model, vLLM does the same on a local server with xgrammar behind it, and the Python SDKs hand back a parsed object rather than a string.
How do you define a contract the model can't quietly break?
The first step is a Pydantic model the application owns — not the prompt. The tutorial's Invoice contract has four fields: a required invoice_id string, and three fields typed as nullable but not optional, meaning the model may return null to say the invoice doesn't state something but may not stay silent or invent a number to avoid a null. Cents are stored as an integer so that 1,250.00 never drifts into 1249.9999.
Two configuration options do most of the work. Strict mode switches off Pydantic's default coercion, which would otherwise silently convert the string "125000" into an integer or turn the boolean True into 1; in strict mode both fail with an int_type error. Setting extra="forbid" turns an unexpected key into an extra_forbidden error rather than ignoring it. Fed four bad inputs — an out-of-set currency, a numeric string, a boolean and a stray key — the contract returns four distinct error types: literal_error, int_type, int_type and extra_forbidden.
Why can't a JSON schema catch the wrong total?
Because the rule that matters can't be expressed in the schema. The tutorial points out that exporting the model's JSON schema yields four properties, all required, with additional properties forbidden — and not a word about the invoice itself. The requirement that the cents match the quoted line has no place in JSON Schema, so even a decoder that enforces the schema perfectly will still produce the 12,500-cent total from the opening example. A valid object, Davies writes, is the entry ticket to the next check, not the result.
How does the source check actually work?
The tutorial's accept() function runs four steps and ends in one of four states: accepted, needs_review, rejected or shape_error. Step one parses and validates the shape, and any failure there is a shape error. Step two requires the quoted line to appear in the source verbatim. Step three checks that the invoice id and currency match what the source states. Step four extracts the digits from the quoted line and compares them with the reported total in cents.
Only if every check passes does the object reach accepted. If the source never stated an amount or currency, the result is needs_review rather than a silent guess; any failed check produces rejected with the reasons attached. This is where the opening invoice's 12,500-cent total is finally caught — not by the schema, but by step four comparing it to the line it claims to quote.
How to try this in Grafy
For developers who want to prototype the pattern before wiring it into production, Grafy Code is an AI coding studio that installs packages and runs code, which makes it a practical place to build the Pydantic contract, the accept() function and a small fixture suite of bad inputs like the ones in the tutorial. To compare how different models respond to the same extraction prompt — and see how often each produces a shape error versus a wrong-but-valid total — Grafy Chat puts many models side by side. And to read the source tutorial or related papers on structured generation and constrained decoding in depth, Reader can search and question PDFs and papers with AI.
Frequently asked questions
Does the tutorial say a valid JSON schema is enough for reliable output?
No. According to the tutorial, a schema can confirm that every key is present and correctly typed, but it cannot express a rule like "the total in cents must match the quoted line." A decoder that enforces the schema perfectly will still return the wrong 12,500-cent total, because that integer is schema-valid. The check against the source text is what catches it.
When does the tutorial say a retry is worth doing?
Only when another attempt can fix what went wrong. The tutorial's retry layer regenerates on shape errors — a missing key or unparseable JSON — and stops hard at three attempts. It also warns about the SDK's hidden transport retries, which need to be kept under control so a retry loop does not multiply calls you did not intend to make.
What four states can the accept() function return?
The tutorial's accept() function returns accepted, needs_review, rejected or shape_error. Shape errors come from a parse or validation failure in step one. Rejected means a check against the source failed, such as a quote not appearing verbatim or a total that disagrees with the quoted line. Needs_review is returned when the source did not state the amount or currency, so nothing is invented.


