takeaway notes
Structured Outputs: Reliable JSON from a Model
After this you can choose the right rung for structured output: prompting only shapes what goes in the fields, JSON mode gives valid JSON, and schema-constrained decoding gives your schema by construction for the schema subset the provider supports. You can use tool calling when the model should trigger an action, validate every output anyway and retry with the error, check the stop reason and refusals before parsing, design small explicit schemas, and plan fallbacks so a half-parsed object never reaches your database.
Concepts
Why please fails
Theory
A model doesn't follow rules; it predicts the next token, and every token is a probability, so the most a prompt can honestly promise is usually. Failures come in flavors: chatty preamble, code fences, a trailing comma, a missing closing brace, a renamed key. Prompting still helps with what goes in the fields, but it can't guarantee the shape of the container.
Practical example: Sure! Here's your JSON
Saying it again in capital letters doesn't help, and patching each failure with a regex leaves you maintaining a tiny, haunted parser.
import json
reply = 'Sure! Here is your JSON: {"age": 30} Have a great day!'
try:
json.loads(reply)
except ValueError as err:
print("parser says:", err) # the chatty preamble breaks itJSON mode
Theory
Many providers offer a switch that promises the output is valid JSON, which fixes the chatter, the code fences and the missing braces. But valid JSON is not the same as your JSON: it checks the grammar, not what you meant (details vary by API). Fine for prototypes; for anything with a schema, climb higher.
Practical example: Valid, and useless
Ask for an order with an id and a total, and you might get order_ID and amount instead.
import json
reply = '{"order_ID": "A17", "amount": 42}' # valid JSON, wrong keys
order = json.loads(reply) # parses fine
print("id" in order, "total" in order) # False False: not your JSONConstrained decoding
Theory
Schema-constrained decoding changes how the model writes: the schema is compiled into a grammar, a state machine that knows which tokens may come next, and every token that would break the schema is blocked. The first request with a new schema can be slower; later ones reuse the work. Types, required keys and enums then match your schema by construction, for the supported schema subset, with refusals and max-token cutoffs as exceptions.
Practical example: Pending is not on the menu
If the schema says status is open or closed and the model wants to say pending, the decoder won't let it. Hosted APIs and open-source engines both do this, though the schema features they allow differ.
Tool calling
Theory
You describe a function with a JSON schema and the model answers by calling it with arguments; even if you never run the function, those arguments are structured output. Use tools when the model should trigger something, and a response schema when you just want the answer in a shape. A strict setting makes tool arguments follow the schema (setting names and rules differ); without it, treat them as best effort.
Practical example: Parse, then trust
In some APIs the arguments arrive as a string. Parse them, validate them, and only then run anything, especially the delete button.
import json
arguments = '{"order_id": "A17"}' # in some APIs, tool arguments arrive as a string
args = json.loads(arguments) # parse...
if not isinstance(args.get("order_id"), str): # ...then validate, then trust
raise ValueError("order_id: expected string")
print("look up order", args["order_id"])Validate and retry
Theory
Validate anyway: your code is the last line of defense. A schema validator (Pydantic in Python, Zod in TypeScript) gives you a typed object or a list of exactly what went wrong. Feed that error back to the model and ask again, capped at two or three attempts, and add your own rules, like dates in the future or totals that don't add up.
Practical example: A repair loop
The model returned age as the word thirty; the validator says age, expected integer; you send that back and the next try returns the number. Log every failure with the raw output: that's your next eval dataset.
import json
def call_model(prompt): # stub: the second try fixes the named mistake
return '{"age": 30}' if "expected integer" in prompt else '{"age": "thirty"}'
def validate(text):
data = json.loads(text)
if not isinstance(data.get("age"), int):
return None, "age: expected integer"
return data, None
prompt = "Extract the age as JSON."
for attempt in range(3): # cap the repair loop
data, error = validate(call_model(prompt))
if data:
break
prompt += "\nYour last answer failed validation: " + error
print(data)What it misses
Theory
Constrained decoding doesn't guarantee four things. Meaning: valid is not correct. Truncation: hit the max token limit mid-object and you get half a JSON. Refusals, which can take precedence over your schema. And the schema subset: providers support only part of JSON Schema, and unsupported keywords can be rejected. Free-text fields are still free text.
Practical example: Check before you parse
Check for a refusal first, then the stop reason (named differently per API), and only then parse. Test your real schema on your real model.
import json
def parse(stop_reason, text): # stop reason names differ per API
if stop_reason == "refusal":
return None, "refused" # check for a refusal first
if stop_reason in ("max_tokens", "incomplete"):
return None, "truncated" # ran out of tokens: don't trust the shape
return json.loads(text), None
print(parse("max_tokens", '{"category": "bil'))Schema design
Theory
Since the schema steers the model, design it like an API. Use enums for fixed lists, mark the fields you need as required, write descriptions, keep it small and shallow, and let the model say I don't know with a nullable field. Huge schemas compile slower, can hit provider limits, and give the model more ways to go wrong.
Practical example: A small, explicit schema
A key called date is a mystery; invoice date, ISO format, is a spec. If every field is mandatory with no way out, the model will fill in a guess. Enum casing isn't guaranteed everywhere, so compare values case-insensitively.
ticket_schema = {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "bug", "other"]},
"invoice_date": {
"type": ["string", "null"],
"description": "Invoice date, ISO format. Null if unknown.",
},
},
"required": ["category", "invoice_date"],
"additionalProperties": False,
}Fallbacks
Theory
Plan the fallbacks before the pager does. Level one: retry with the error fed back. Level two: change the setup with a simpler schema, shorter input or a bigger output limit. Level three: a cheap repair step with a tested library, not a midnight regex. Level four: fail gracefully with a default, a human queue, or an honest error.
Practical example: Measure the rate
Never let a half-parsed object into the database. Track parse failures, retries and refusals like any metric; if one call in ten needs a retry, that's a schema bug.
Checklist
- Replace please return JSON with schema-constrained output or a strict tool definition where your API supports it.
- Validate every model output with a schema validator, even with constrained decoding on.
- Add a repair loop that feeds the validation error back to the model, capped at two or three attempts.
- Check for refusals and the max-token stop reason before parsing.
- Tighten your schema: enums for fixed lists, required fields, clear descriptions, fewer and shallower fields, and a nullable escape hatch.
- Log every parse failure with the raw output, add it to your eval set, and put failure, retry and refusal rates on a dashboard.
Gotchas
- Treating JSON mode as schema mode. It promises valid JSON, not your keys; use constrained decoding or validate.
- Assuming a schema-valid object is a correct one. The values can be confident nonsense; add your own business rules and evals.
- Parsing before checking the stop reason. A max-token cutoff gives half a JSON; check for refusals and truncation first.
- Making every field mandatory. The model will guess; give it a nullable field or an unknown option.
- Running tool arguments before validating them. Parse, validate, then act.
References
- Structured outputs (OpenAI API docs)
- Structured outputs (Claude docs)
- Structured output (Gemini API docs)
- Function calling (OpenAI API docs)
- Efficient Guided Generation for Large Language Models (Willard and Louf, 2023)
- JSON validation (Pydantic docs)
- Understanding JSON Schema: enum
- Understanding JSON Schema: object