TAI BUI
Back to curriculum
BuildPython

Structured Output — JSON Schema, Pydantic, Zod, Constrained Decoding

"Ask the model nicely to return JSON" fails 5 to 15 percent of the time, even on frontier models. Structured outputs close that gap with constrained decoding: the decoder masks any token that would violate the schema. OpenAI's strict mode, Anthropic's schema-typed tool use, Gemini's `responseSchema`, Pydantic AI's `output_type`, and Zod's `.parse` are five surface forms of the same idea. This lesson builds a schema validator and the three-outcome contract you will reuse in every production extraction pipeline.

TIME: ~75 minutesPREREQUISITES: Phase 13 · 02 (function calling deep dive)

Learning Objectives

  • Write a JSON Schema 2020-12 for an extraction target using the right constraints (enum, min/max, required, pattern).
  • Explain why strict mode and constrained decoding give different guarantees from "validate after generation".
  • Distinguish the three failure modes: parse error, schema violation, model refusal.
  • Ship an extraction pipeline with typed repair and typed refusal handling.

The Problem

An agent reading a purchase-order email needs to turn free text into {customer, line_items, total_usd}. There are three ways to do it.

Approach one: prompt for JSON. "Reply in JSON with fields customer, line_items, total_usd." This works 85 to 95 percent of the time on frontier models. The rest fail in six ways: a missing brace, a trailing comma, wrong types, hallucinated fields, truncation at the token limit, and leaked prose like "Here is your JSON:".

Approach two: validate after generation. Generate freely, parse, validate against the schema, retry on failure. It is reliable but you pay for every retry, and each truncation bug costs one extra turn.

Approach three: constrained decoding. The provider enforces the schema at decode time. Invalid tokens are masked out of the sampling distribution, so the output parses and validates. Failure collapses to one mode, refusal, where the model decides the input does not fit the schema.

Every 2026 frontier provider ships some form of approach three.

  • OpenAI. response_format: {type: "json_schema", strict: true}, plus a refusal in the response if the model declines.
  • Anthropic. Schema enforcement on tool_use inputs. There is no stop_reason: "refusal"; the signal is end_turn with no tool call.
  • Gemini. responseSchema at request level. In 2026 Gemini ships token-level grammar constraints for selected types.
  • Pydantic AI. output_type=InvoiceModel returns a RunResult typed to InvoiceModel.
  • Zod (TypeScript). A runtime parser that validates provider output against a Zod schema. It pairs with OpenAI's beta.chat.completions.parse.

Declare the schema once and enforce it end to end.

Warm-up0/3
1. In function calling, what does the model return when it wants your code to run a tool?Warm-up
A user prompt asking for the weather in Paris, a Model box, a dashed box asking what the model returns, and a Your code box that defines get_weather(city).
2. Why should code never trust tool-call arguments exactly as a model produced them?Warm-up
A Model box that writes arguments as text, an arrow to a Your code box, and an arrow to a dashed box with a question mark. A label names the tool get_weather(city: string).
3. A Python script runs `json.loads` on the text `{"a": 1,}`, which has a trailing comma. What happens?Warm-up
A Python prompt that imports json and calls json.loads on the text {"a": 1,} with a trailing comma, followed by a line saying the output is not shown.
Check: The Problem0/7
1. What is strict mode in OpenAI's structured outputs?Define
A request fragment with response_format of type json_schema and a strict field whose value is a question mark, with an arrow to a dashed box labelled strict mode.
2. What is constrained decoding?Define
A partial invoice, {"customer": "Acme", "total_usd": , followed by a row of six candidate next tokens: 12, "twelve", }, Here, 3.5 and 0.
3. How often does simply asking a frontier model to reply in JSON fail?Define
A prompt asking a model to reply in JSON with fields customer, line_items and total_usd, an arrow to a frontier model box, and a dashed box asking whether the reply is valid JSON.
How often it fails is what the question asks, so no rate is drawn.
4. Under constrained decoding, what does the set of failure modes collapse to?Explain
A column listing three failure modes, parse error, schema violation and refusal, an arrow labelled constrained decoding, and a tall dashed box with a question mark for the failure modes that remain.
5. Why does validate-after-generation give a weaker guarantee than constrained decoding?Explain
Two rows. The first is a box labelled Approach two, validate after generation, and the second is Approach three, constrained decoding. Each has an arrow to a dashed box reading guarantee with a question mark.
6. Which pairing of provider or framework and its structured-output mechanism is correct?Explain
A table with five rows named OpenAI, Anthropic, Gemini, Pydantic AI and Zod (TypeScript), each with a question mark in the mechanism column.
7. An Anthropic tool-use response ends with `stop_reason: "end_turn"` and contains no tool call. What is the most likely reading?Analyze
An Anthropic response with stop_reason end_turn and content that has no tool_use block, an arrow to a dashed box labelled reading.

The Concept

JSON Schema 2020-12, the lingua franca

Every provider accepts JSON Schema 2020-12. These are the constructs you will reach for most:

  • type: one of object, array, string, number, integer, boolean, null.
  • properties: map of field name to subschema.
  • required: list of field names that must appear.
  • enum: closed set of allowed values.
  • minimum / maximum (numbers), minLength / maxLength / pattern (strings).
  • items: subschema applied to every array element.
  • additionalProperties: false forbids extra fields (default varies by mode).

OpenAI strict mode adds three requirements: every property must be listed in required, additionalProperties: false must be set at every object level, and no $ref can be left unresolved. Break one and the API returns a 400 at request time.

Pydantic, the Python binding

Pydantic v2 generates JSON Schema from dataclass-shaped models via model_json_schema(). Pydantic AI wraps this so you write:

python
class Invoice(BaseModel):
    customer: str
    line_items: list[LineItem]
    total_usd: Decimal

and the agent framework translates the schema into OpenAI strict mode, Anthropic input_schema, or Gemini responseSchema at the edge. The output comes back as a typed Invoice instance, and validation errors raise ValidationError with typed error paths.

Zod, the TypeScript binding

Zod (z.object({customer: z.string(), ...})) is the TS equivalent. OpenAI's Node SDK exposes zodResponseFormat(Invoice), which translates to the API's JSON Schema payload.

Refusals

Strict mode cannot force the model to answer. If the input cannot fit the schema ("the email was a poem, not an invoice"), the model emits a refusal field with the reason. Handle it as a first-class outcome, not a failure. It doubles as a safety signal: a model asked to extract a credit card number from a protected-content email returns a refusal with the safety reason attached.

Constrained decoding in the open

Open-weights implementations use three techniques.

  1. Grammar-based decoding (outlines, guidance, lm-format-enforcer): build a deterministic finite automaton from the schema; at every step, mask the logits of tokens that would violate the FSM.
  2. Logit masking with a JSON parser: run a streaming JSON parser in lockstep with the model; at every step, compute the valid-next-token set.
  3. Speculative decoding with a verifier: cheap draft model proposes tokens, verifier enforces the schema.

Commercial providers pick one of these behind the scenes. The 2026 state of the art is faster than plain generation for short structured outputs and roughly the same speed for long ones.

The three failure modes

  1. Parse error. The output is not valid JSON. Strict mode rules this out, but non-strict providers can still produce it.
  2. Schema violation. The output parses but breaks the schema. Strict mode rules this out too. Outside it, this one is common.
  3. Refusal. The model declines. Handle it as a typed outcome.

Retry strategy

When you are outside strict mode (Anthropic tool use, non-strict OpenAI, older Gemini), the recovery pattern is:

generate -> parse -> validate -> if fail, inject error and retry, max 3x

One retry usually fixes it. Three catch the flakes from weaker models. If you need more than three, suspect the schema: the model cannot satisfy it for some inputs, and the prompt or the schema needs fixing.

Small-model support

Constrained decoding works on small models. A 3B-parameter open model with grammar enforcement outperforms a 70B-parameter model with raw prompting on structured tasks. That is the main reason structured outputs matter in production: reliability stops depending on model size.

Check: The Concept0/21
1. What is JSON Schema 2020-12 in the context of structured output?Define
A file named invoice.schema.json whose first property is a $schema URL ending in draft/2020-12/schema, followed by type object and a properties map with one string field, customer.
2. Which JSON Schema keyword lists the fields that must be present in an object?Define
Two JSON documents side by side. Document A has customer, line_items, total_usd and currency and is accepted. Document B has no customer field and is rejected. A box below asks which keyword causes the rejection.
3. What is a refusal in structured output?Define
A four-line poem as email text, an arrow to an Extractor box that uses the Invoice schema, and an arrow to a dashed box labelled outcome.
4. What is a parse error?Define
A three-row table with the terms parse error, schema violation and refusal, each with a question mark as its meaning and the parse error row highlighted, above two lines of code that call json.loads on the raw model text.
5. What is a schema violation?Define
A three-row table with the terms parse error, schema violation and refusal, each with a question mark as its meaning and the schema violation row highlighted, above a line of code that calls validate with INVOICE_SCHEMA.
6. What does `additionalProperties: false` do on an object schema?Define
An object schema with type object, properties customer (string) and total_usd (number), and an additionalProperties line set to false that is highlighted.
7. What is a Pydantic BaseModel used for in structured output?Define
A Python file with class Invoice(BaseModel) and three typed fields: customer as str, line_items as list of LineItem, and total_usd as Decimal.
8. What is a Zod schema?Define
A TypeScript line reading z.object({customer: z.string(), ...}).
9. What is grammar enforcement in open-weights models?Define
A small schema fragment, an arrow to a dashed box with a question mark, and an arrow to an open-weights model box.
10. Which open-weights technique runs a streaming JSON parser in lockstep with the model to find the valid next tokens at every step?Define
The partial text {"customer": "Acme", "total_usd": , an arrow down to a box for a streaming JSON parser that runs alongside the model, and an arrow to a dashed box labelled valid next tokens.
11. Which three requirements does OpenAI's strict mode add on top of plain JSON Schema?Explain
A box for plain JSON Schema 2020-12 with an arrow down to three dashed numbered boxes with question marks, under the heading OpenAI strict mode adds three requirements.
12. Strict mode rules out parse errors and schema violations. Why can it still produce a refusal?Explain
A four-line poem as email text, an arrow to a box reading Strict mode on with the Invoice schema, and an arrow to a dashed box labelled response.
13. How should your code treat a strict-mode refusal?Explain
A response fragment with content null and a refusal field reading The email is a poem, not an invoice, an arrow to a dashed box labelled your code.
14. What retry policy does the Retry strategy part recommend outside strict mode?Explain
A box reading outside strict mode, an arrow down to a validation fails box, and an arrow to a dashed box labelled retry policy.
15. Why do structured outputs matter in production beyond raw reliability?Explain
A row of three boxes, model output, your code and downstream system, joined by arrows, above a dashed box asking what structured outputs change here beyond raw reliability.
16. A strict-mode request fails with a 400 before any text is generated. The schema has an optional `notes` field that is missing from `required`. Which rule did the schema break?Analyze
A schema fragment with properties customer and notes and a required list holding only customer, with an arrow to a strict-mode request box that ends in HTTP 400 with no text generated.
17. A pipeline retries every failure up to 3 times with the error injected. Poem inputs come back as a refusal every time, wasting four calls each. What is the wrong assumption?Analyze
A retry log for a poem input: call 1 and retries 1 to 3 each end in a refusal, four calls in total, under the rule retry every failure up to 3 times.
18. An Anthropic tool-use pipeline still fails validation on one input type after 3 retries with the typed error injected. What should you suspect first?Analyze
A log of an Anthropic tool-use pipeline on one input type: the first attempt and three retries all fail validation, above a dashed box labelled suspect first.
19. Your Anthropic tool-use pipeline returns 1 invalid payload in 20, and a single retry with the validation error fixes most of them. What is the right design?Design
A grid of 20 payload squares, 19 outlined as valid and 1 as invalid, a note that a single retry with the validation error fixes most invalid payloads, and a dashed box labelled design.
Counts come from the question.
20. You must extract support tickets with a self-hosted 3B open model, and the output has to always parse. Which design fits?Design
Three stacked requirement boxes: a self-hosted 3B open model, the task of extracting support tickets, and the rule that output must always parse, above a dashed box labelled design.
21. You are writing the response handler for an extraction endpoint that runs under OpenAI strict mode. Which outcomes does it need to distinguish?Design
An extraction endpoint box marked OpenAI strict mode, an arrow to a response handler box, and an arrow to a dashed box labelled outcomes.

Use It

main.py ships a minimal JSON Schema 2020-12 validator in stdlib (types, required, enum, min/max, pattern, items, additionalProperties). It wraps an Invoice schema and runs a fake LLM output through the validator, which exercises the parse error, schema violation, and refusal paths. In production, swap the fake output for any provider's real response.

What to look at:

  • The validator returns a typed [ValidationError] list with a path and a message. That is the shape you want to feed into the retry prompt.
  • The refusal branch does not retry. It logs and returns a typed refusal. Phase 14 · 09 uses refusals as a safety signal.
  • The additionalProperties: false check fires on the adversarial test input, which shows why strict mode shuts the door on hallucinated fields.
Check: Use It0/9
1. What does the validator in `main.py` return on a schema violation, and what does the refusal branch do?Explain
Two function signatures from main.py, validate and process_model_output with their return types hidden, above two dashed boxes asking what the validator returns on a violation and what the refusal branch does.
2. Why does `process_model_output` check for a refusal before it calls `json.loads`?Explain
Three boxes in a row: raw model text, a refusal check labelled first, and json.loads(raw) labelled second.
3. `main.py` receives `{"customer": "Acme", "line_items": [], "total_usd": 0, "currency": "USD",}` with a trailing comma. What does `process_model_output` return?Apply
A call to process_model_output with a raw string that is an invoice object ending in a comma before the closing brace, and a dashed box asking what it returns.
The raw string is exactly the one in the question.
4. `main.py` validates `{"customer": "Acme", "line_items": [{"sku": "abc_123", "qty": 1, "unit_usd": 10, "discount": 0.1}], "total_usd": 10, "currency": "USD"}`. What comes back?Apply
The JSON text given to main.py: customer Acme, one line item with sku abc_123, qty 1, unit_usd 10 and discount 0.1, total_usd 10 and currency USD, with an arrow to a dashed result box.
The input is exactly the one in the question.
5. `main.py` validates `{"customer": "Acme", "line_items": []}`. What comes back?Apply
The JSON text given to main.py, an object with customer Acme and an empty line_items list, with an arrow to a dashed result box.
The input is exactly the one in the question.
6. An otherwise valid invoice reaches `main.py` with `"total_usd": -5`. What does `process_model_output` return?Apply
A JSON invoice with total_usd set to -5, customer Acme, an empty line_items list and currency USD, with an arrow to a dashed result box.
7. `main.py` receives `__REFUSAL__ The provided text is a song lyric, not an invoice.` What does it return?Apply
The text __REFUSAL__ The provided text is a song lyric, not an invoice. given to main.py, with an arrow to a dashed result box.
The input is exactly the one in the question.
8. In `INVOICE_SCHEMA`, a `sku` must match `^[A-Z0-9-]+$`. Which value passes?Apply
The sku line of INVOICE_SCHEMA with pattern ^[A-Z0-9-]+$, above a dashed box asking which sku value passes.
9. A developer moves the refusal check in `process_model_output` to after `json.loads`. What happens to the song-lyric refusal?Analyze
The process_model_output function with json.loads in a try block first and the __REFUSAL__ prefix check afterward, the song-lyric refusal as input, and a dashed box asking for the returned kind.
Edited copy of main.py, typed here to show the order.

Ship It

This lesson produces outputs/skill-structured-output-designer.md. Give it a free-text extraction target (invoices, support tickets, resumes) and it returns a strict-mode-compatible JSON Schema 2020-12 and a matching Pydantic model, with typed refusal and retry handling stubbed in.

Check: Ship It0/1
1. What does the structured-output-designer skill produce from a free-text extraction target?Define
A box naming free-text extraction targets (invoices, support tickets, resumes), an arrow to a box for the structured-output-designer skill, and an arrow to a dashed box with a question mark. A line names the skill file path.
Author's Note

I'd run main.py first and read the five test cases against the schema. It needs nothing beyond the standard library, and each case lands in a different branch of process_model_output: ok, parse_error, violation (twice, once for extra fields and a bad sku, once for a missing field), and refusal. Seeing which error paths come back for each is faster than reading the validator top to bottom.

The part I'd look at hardest is the refusal check, which runs before json.loads. In the toy it is a __REFUSAL__ string prefix standing in for a provider's refusal field, so a refusal never gets mistaken for a parse error. Real providers hand you that signal differently, and the Anthropic case is the awkward one, since you infer it from end_turn with no tool call.

I'd also keep the validator in proportion. It covers a subset of the spec and has no oneOf and no $ref, so it will not stand in for a real library on a production schema. It is there to show the error shape you want to feed back into a retry prompt. The retry loop itself is not in the file, so the pattern in The Concept is yours to build, and exercise 2 covers the missing oneOf.

Exercises

  1. Run main.py. Add a sixth test case whose total_usd is a negative number. Confirm the validator rejects it with the minimum constraint path.

  2. Extend the validator to support oneOf with a discriminator. The common case: line_item is either a product or a service, tagged by kind. Strict mode has subtle rules here; check OpenAI's structured outputs guide.

  3. Write the same Invoice schema as a Pydantic BaseModel and compare model_json_schema() output to your hand-rolled schema. Identify the one field Pydantic sets by default that the hand-rolled version omits.

  4. Measure refusal rates. Construct ten inputs that should not be extractable (a song lyric, a math proof, a blank email) and run them through a real provider with strict mode. Count refusals vs hallucinated outputs. This is your ground truth for refusal-aware retries.

  5. Read OpenAI's structured outputs guide top to bottom. Identify the one construct it explicitly forbids in strict mode that plain JSON Schema allows. Then design a schema that uses the forbidden construct non-essentially and refactor it to be strict-compatible.

Key Terms

TermWhat people sayWhat it actually means
JSON Schema 2020-12"The schema spec"IETF-draft schema dialect every modern provider speaks
Strict mode"Guaranteed schema"OpenAI flag that enforces schema via constrained decoding
Constrained decoding"Logit masking"Decode-time enforcement that masks invalid next-tokens
Refusal"Model declines"Typed outcome when input cannot fit the schema
Parse error"Invalid JSON"Output did not parse as JSON; impossible under strict
Schema violation"Wrong shape"Parsed but violated types / required / enum / range
additionalProperties: false"No extras allowed"Forbids unknown fields; required in OpenAI strict
Pydantic BaseModel"Typed output"Python class that emits and validates JSON Schema
Zod schema"TypeScript output type"TS runtime schema for provider output validation
Grammar enforcement"Open-weights constrained decode"FSM-based logit masking, as in outlines / guidance

Further Reading

Code

Ships With