Twelve Categories From Four
Your triage dashboard declares four categories. Week two shows twelve rows:
| Observed value | Tickets |
|---|---|
billing |
412 |
Billing |
88 |
invoicing |
51 |
billing + trailing space |
17 |
Nothing crashed. Every response parsed. The damage came from allowing free text where a closed set was required, so a plain string field let "Billing" through as legally as "billing". Patching that in application code means maintaining a synonym map forever. Binding it in the request means the bad strings never exist:
import json
from openai import OpenAI
SCHEMA = {
"name": "ticket_classification",
"strict": True,
"schema": {
"type": "object",
"properties": {
"category": {"type": "string",
"enum": ["billing", "shipping", "account"],
"description": "Owning team for this ticket"},
"priority": {"type": "string", "enum": ["low", "med", "high"]},
"summary": {"type": "string"},
},
"required": ["category", "priority", "summary"],
"additionalProperties": False,
},
}
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": ticket}],
response_format={"type": "json_schema", "json_schema": SCHEMA},
)
payload = json.loads(resp.choices[0].message.content)
With strict: true, a token continuation producing "Billing" or a trailing space is not among the legal next steps, so it cannot be sampled. Note what strict mode demands in return: every property listed in required and additionalProperties: false. A key the model may omit or a property it may invent would break the grammar, so optional data is modeled as an explicitly nullable field rather than an absent one.
Each layer of the stack catches a different class of defect:
| Layer | Catches | Misses |
|---|---|---|
| Plain prompting | nothing reliably | everything |
| JSON mode | syntax errors | keys, types, enums |
| JSON Schema binding | syntax, keys, types, enums | cross-field rules, semantic wrongness |
| Application validation | cross-field rules, business limits | anything the model breaks legitimately |
From Schema to Allowed-Token Mask
A model emits one token per step from a vocabulary of roughly a hundred thousand candidates. Constrained decoding narrows that choice before sampling rather than filtering after:
JSON Schema ──► grammar automaton ──► allowed-token mask (rebuilt each step)
│
model logits ───────────────────────────────┼──► masked logits ──► sampled token
│
disallowed tokens set to -inf, so they never win
Because the mask applies before sampling, an invalid continuation is unreachable, not "generated then discarded". There is no parse-fail-retry cycle for syntax or shape at all — contrast the round trips each structural defect costs in JSON Mode Outputs, where repair is always one attempt too late.
Three consequences follow from paying for that guarantee:
- The schema is input tokens on every request. A 900-token schema on a high-volume endpoint is a line item, not a footnote, and providers that compile the grammar server-side add pre-processing latency before the first token.
- Constraints are per-field, not relational. No JSON Schema construct says
refund_amount <= order_totalor "the quoted text must appear in the retrieved document". Those live in code after generation. - Support is not uniform. Not every provider or model version implements binding, and strict-mode rules have shifted across API versions. Keep the JSON-mode validate-and-retry path as a fallback so a provider migration does not rewrite your output layer.
Depth is a second cost paid at generation time. Each nesting level is a decision the model must hold open while it emits siblings, so a legal schema can still fail often purely because of its shape, which the final chapter takes up directly.
Binding a Pydantic Model to the Response
Declaring the schema as a dict works, but two sources of truth — the dict and the Python type that consumes it — drift apart silently. Deriving the request schema from a Pydantic model removes that seam:
from typing import Literal
from pydantic import BaseModel, Field
from openai import OpenAI
class Classification(BaseModel):
category: Literal["billing", "shipping", "account"] = Field(
description="Owning team for this ticket")
priority: Literal["low", "med", "high"]
summary: str
client = OpenAI()
def classify(ticket: str) -> Classification:
completion = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[{"role": "user", "content": ticket}],
response_format=Classification, # schema generated from the model class
)
message = completion.choices[0].message
if message.parsed is None:
raise ValueError(f"refusal or early stop: {message.refusal or 'no parsed output'}")
return message.parsed
input: "I was charged twice for order 88213 and nobody has replied."
output: Classification(category='billing', priority='high',
summary='Duplicate charge on order 88213, no agent reply')
Line by line on what is non-obvious:
Literal[...]rather thanstris the whole point of chapter one. A plainstrgenerates"type": "string"in the schema and"Billing"becomes samplable again; aLiteralgenerates anenum.response_format=Classificationserializes the class into thejson_schemablock, so the validator and the contract cannot disagree about field names.message.parsedreturns the instance directly — skipjson.loadsentirely. It isNonewhen the model refuses or stops before the object closes, which is why the guard precedes use.- Pydantic's own
@model_validatorrules run after parsing. Their errors describe cross-field problems the schema could not express, and they should be fed back through the same corrective retry path described in JSON Mode Outputs.
This pattern is the practical core of Pydantic Basics; here it is what turns a declared contract into a typed object your code can trust.
Schema Shape and Cross-Field Rules
Schema validity is not generation reliability. A schema can pass every linter and still fail repeatedly because the shape itself is hard to satisfy token after token.
Flatten before nesting hurts. Four or five object levels deep, the errors compound:
{"customer": {"id": "c_88", "address": {"line1": "12 Rue Victor", "zip": "75011"}}}
The same information, produced reliably:
{"customer_id": "c_88", "address_line1": "12 Rue Victor", "address_zip": "75011"}
| Shape choice | Guidance |
|---|---|
| Categorical value | enum, always — never a free string |
| Nesting depth | 1 level comfortable, 2 workable, 4+ a reliability tax |
| Arrays | arrays of uniform flat objects are fine; keep the item schema shallow |
| Property descriptions | write them — they are instructions the model reads mid-generation |
| Property names | freeze them; renaming breaks every consumer at once |
Unions (anyOf) |
prefer one stable object shape over several alternatives |
Descriptions carry weight a type cannot: {"type": "string", "description": "ISO 8601 date of the incident, not the report date"} prevents a whole class of field mix-ups.
What the schema cannot say. Rules that compare two fields live in code:
from datetime import date
def check_invariants(p: dict) -> list[str]:
problems = []
start = date.fromisoformat(p["start_date"])
end = date.fromisoformat(p["end_date"])
if start >= end:
problems.append(f"start_date {start} must precede end_date {end}")
if p.get("refund_amount", 0) > p["order_total"]:
problems.append(f"refund_amount {p['refund_amount']} exceeds {p['order_total']}")
return problems
Treat those failures like schema failures: state them explicitly and push them back through the retry loop, because the model usually repairs a cross-field mistake once it is named.
Drift runs in two directions. Your schema evolves — adding a required field breaks older consumers, so version the schema name (ticket_classification_v2) and migrate consumers before flipping strict mode. Independently, provider model versions change how reliably the same schema is satisfied. Guard both with ten to twenty representative prompts per schema, including adversarial ones, executed whenever the schema or model version changes: Regression Suites shows how to wire that into CI so drift surfaces before your users do.
Quiz
1. A dashboard shows category values "billing", "Billing", and "invoicing" from the same prompt. What is the root cause?
2. During constrained decoding, what happens to a token that would violate the schema?
3. Why does strict mode require every property to be listed in required with additionalProperties false?
4. A schema with four nested object levels validates but keeps failing during generation. Best first move?
Flashcards
Question
What does constrained decoding do with a token that violates the schema?
Click to reveal answer
Answer
It masks it to -inf before sampling, so the invalid token cannot win. Violations are unreachable, not detected after the fact.
Question
Why does strict mode demand every property in required plus additionalProperties false?
Click to reveal answer
Answer
An omitted key or an invented property would break the grammar. Model optional inputs as explicitly nullable fields instead.
Question
What is the practical rule for schema nesting depth?
Click to reveal answer
Answer
One level is comfortable, two is workable, four or more is a reliability tax - flatten into prefixed scalar fields instead.
Question
Where do cross-field invariants like start_date < end_date get enforced?
Click to reveal answer
Answer
In application code after generation; schemas constrain single fields only. Feed violations back through the corrective retry path.
Question
Why prefer Literal over str for a categorical field in a Pydantic output model?
Click to reveal answer
Answer
Literal generates an enum in the schema, so drift becomes unsamplable. A plain str declares type string and lets Billing through again.
Revision Notes
Key Takeaways
- 1. Constrained decoding makes invalid output unreachable instead of merely detected
- 2. Enums in the schema remove taxonomy drift at the source; Literal does the same from Pydantic
- 3. Schema depth and required surface are reliability costs, not style choices
- 4. Cross-field business rules need post-generation validation wired into the retry path
- 5. Version schemas and regression-test them against model upgrades
Interview Tips
- • Explain masking in one sentence - "disallowed tokens are set to -inf before sampling" - then contrast it with retry loops and their round-trip cost
- • Be ready for "why did my valid schema still produce wrong output?" - answer with shape versus meaning, plus cross-field rules
- • Discuss schema token cost when an interviewer raises high-volume structured endpoints
- • Ask whether the API strict mode forces all fields required; knowing that constraint signals hands-on experience
Cheat Sheet
Schema-Constrained Generation — Cheat Sheet
Enable strict binding:
response_format={"type": "json_schema", "json_schema": {
"name": "ticket_classification",
"strict": True,
"schema": {"type": "object", "properties": {...},
"required": [...], "additionalProperties": False}
}}
Pydantic binding
class Classification(BaseModel):
category: Literal["billing", "shipping", "account"]
priority: Literal["low", "med", "high"]
summary: str
msg = client.beta.chat.completions.parse(..., response_format=Classification
).choices[0].message
result = msg.parsed # instance, or None on refusal / early stop
Literal → enum in the generated schema. Plain str → "type": "string" and drift returns.
Decoding mechanics
- Schema → grammar → allowed-token mask applied before sampling; violations are unreachable, not retried
- Schema tokens are input tokens on every request; grammar compilation can add pre-flight latency
- Per-field only: no construct expresses
start < endorrefund <= total
Shape rules
- Depth: 1 fine, 2 workable, 4+ flatten into prefixed scalars
- Every property gets a description; names frozen after release
- One stable shape beats several
anyOfalternatives - Required = obligations while generating; use defaults/nullable to shrink the set
Cross-field invariants → validate after generation, return violations through the corrective retry path.
Drift control
- Version the schema name on breaking changes
- Keep a 10–20 prompt regression set per schema
- Run it on schema edits and model upgrades
- Retain JSON mode + validate/retry as provider fallback
Continue Learning
Before You Start
- No prerequisites listed.