Structured Output and JSON
Getting reliable JSON with schemas, constrained decoding, validation, and repair.
12 min read · lesson 20 of 49
The moment an LLM's output feeds into code — a database write, an API call, a UI — prose isn't enough; you need structure you can parse every single time. Getting reliable JSON is a layered problem of schema design, decoding, validation, and recovery.
The analogy
Asking a person to "send me the details" gets you an email in any shape. Handing them a form with labeled boxes, dropdowns, and required fields gets you data you can process. Structured output is giving the model a form instead of a blank page.
The reliability ladder
weakest ─────────────────────────────────────────────► strongest "reply in JSON" → JSON mode → schema in prompt + validate/retry → schema-constrained decoding- Instruction only: works most of the time; occasionally you get Markdown fences, trailing prose, or missing fields.
- JSON mode: the provider guarantees syntactically valid JSON but not your shape.
- Tool / function schema: define the output as a tool's parameters; the model "calls" it with arguments matching the schema.
- Strict structured output / constrained decoding: the provider (or a local library) masks invalid tokens so output must match the JSON Schema.
Even at the top of the ladder you still validate: constrained decoding guarantees shape, not truth.
Define the schema once, in code
from typing import Literalfrom pydantic import BaseModel, Field, ValidationErrorclass LineItem(BaseModel): description: str quantity: int = Field(ge=1) unit_price: float = Field(ge=0)class Invoice(BaseModel): vendor: str invoice_number: str currency: Literal["INR", "USD", "EUR"] items: list[LineItem] total: float notes: str | None = Field(None, description="Anything that didn't fit the schema")def extract_invoice(llm, text: str, max_attempts: int = 3) -> Invoice: schema = Invoice.model_json_schema() messages = [ {"role": "system", "content": "Extract invoice data. Return JSON matching the schema."}, {"role": "user", "content": f"<invoice>\n{text}\n</invoice>"}, ] for _ in range(max_attempts): raw = llm.generate(messages, response_schema=schema, temperature=0) try: inv = Invoice.model_validate_json(raw) if abs(sum(i.quantity * i.unit_price for i in inv.items) - inv.total) > 0.01: raise ValueError("line items don't sum to total") return inv except (ValidationError, ValueError) as e: messages += [{"role": "assistant", "content": raw}, {"role": "user", "content": f"Invalid: {e}. Return corrected JSON only."}] raise RuntimeError("extraction failed after retries")Pydantic (or similar) gives you a schema to send, a validator to run, and typed objects for the rest of your code.
Schema design tips
- Use enums for categorical fields; free-text categories drift.
- Make missing data representable: nullable fields or an explicit
"unknown"value. Otherwise the model invents values to satisfy "required". - Field order matters: generation is left to right. Put a
reasoningfield before thedecisionfield if you want the decision informed by reasoning. - Descriptions are prompts: field descriptions guide the model; write them carefully.
- Keep nesting shallow and schemas small; giant schemas increase errors and token cost.
- Add an escape hatch like
notesso odd information has somewhere to go.
Semantic validation
Syntax is the easy part. Add business checks: totals add up, dates are in range, IDs exist in your database, quoted text actually appears in the source. Failing checks trigger a retry with the error message, a fallback model, or human review.
Streaming structured output
For UIs, you can stream partial JSON and parse incrementally with a tolerant parser, rendering fields as they complete. Validate only once the object is closed.
Common mistakes
- Using
json.loadson raw output without stripping code fences or handling truncation (check the finish reason for max-token cut-offs). - Required fields with no way to say "not present" — the top cause of fabricated values.
- Putting the decision before its reasoning in the schema.
- Trusting schema-valid output as correct; validate semantics too.
- Retrying forever; cap attempts and route failures to a queue.
In the interview
Q: How do you get reliable structured output from an LLM? Define a schema in code, use the provider's strict structured-output or tool-calling feature (constrained decoding where available), run at low temperature, validate with Pydantic plus business rules, and retry with the error message, with a capped fallback path.
Q: Why might a model fabricate a field value? Because the schema forces a value and offers no way to express absence. Make fields nullable or add explicit "unknown" options and instruct the model to use them.
Q: Does constrained decoding solve extraction quality? No — it guarantees the output parses and matches the schema, not that values are correct. You still need semantic validation and evals.
Key takeaways
- Climb the ladder: schema-constrained output where available, validation always.
- Pydantic models double as schema and validator.
- Design schemas for the model: enums, nullable fields, reasoning before decisions.
- Validate semantics and retry with errors; cap retries.