AI agents
Key points

Key points

  1. 1JSON Schema plus a provider's strict structured output mode removes the two most common failures, missing keys and invented enum values, by restricting which tokens the model can emit.
  2. 2Flat objects with few properties and explicit enums are followed far more reliably than deeply nested trees, and the practical fix for a deep tree is to split it into several calls.
  3. 3Optional fields are a trap: providers that enforce strict schemas require every property in required, so model "omission" is usually a schema design bug, not a model behavior.
  4. 4Descriptions and one or two inline examples do more for field-level accuracy than any amount of extra prompt text outside the schema.

Why the schema, not the prompt, decides reliability

Asking a model nicely to return JSON fails in predictable ways. The common outputs are a missing closing brace, an extra field the model invented, a string where you wanted a number, a trailing comma, or JSON followed by a chatty explanation (Brenndoerfer on constrained decoding). Those are not model bugs. Each token is sampled from a distribution, and nothing in an unconstrained sampler knows that a closing brace is due three tokens from now.

The fix is to change the sampling, not the wording of the prompt. That is what "structured outputs" means in a modern API: the provider compiles your JSON Schema into a constraint and masks any token that would break it, so invalid continuations get probability zero (ZeroEntropy's constrained decoding explainer). The model's relative preferences among legal tokens are preserved, but illegal tokens simply cannot be emitted.

This has an important consequence for schema design. Once the format is enforced for you, your job stops being "write a prompt that begs for JSON" and becomes "write a schema that encodes the right decision." The OpenAI structured outputs guide puts the benefit plainly: no need to validate or retry incorrectly formatted responses, and model refusals become programmatically detectable instead of hiding inside prose.

The catch is that schema enforcement is not universal. Vellum's comparison of the major providers notes that support for standalone structured outputs varies, and some providers only offer schema-constrained output through function calling rather than a final-response format (Vellum on function calling vs structured outputs). If you run open-weight models, vLLM exposes the same idea through guided_json, guided_choice, guided_regex and guided_grammar parameters that restrict generation to a schema, a choice set, a regex or a context-free grammar (HPE on structured outputs in vLLM). Your schema design should survive all of these paths.

Pattern 1: keep the object flat

The single highest-leverage decision is depth. Every level of nesting is another place where the model must decide what goes inside before it has finished reasoning about what goes outside, and it multiplies the number of schema constructs the provider has to compile.

Compare a flat ticket schema:

{
  "type": "object",
  "properties": {
    "title": { "type": "string", "description": "Short summary, under 80 characters." },
    "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] },
    "category": { "type": "string", "enum": ["billing", "bug", "account", "other"] },
    "is_actionable": { "type": "boolean" },
    "summary": { "type": "string", "description": "One sentence restating the user problem." }
  },
  "required": ["title", "priority", "category", "is_actionable", "summary"],
  "additionalProperties": false
}

with a nested version that puts the same information two levels down under ticket.classification.priority. The flat version is better on three counts. It is easier to read in the schema itself, which matters because the schema is part of the prompt. It is easier for the provider to compile into a token mask. And it maps cleanly onto a database row, so there is no extraction step between the model's output and your insert.

The practitioner's rule: if a field's natural home is a nested object, ask whether the parent object carries any information of its own. In the ticket case it does not, so flattening loses nothing. When the parent does carry information, for example an order with many line items, keep the array but flatten each item to primitives.

The other flatness lever is additionalProperties: false, visible in OpenAI's own Go example where the schema closes the object explicitly (OpenAI structured outputs guide). Closing the object stops the model from inventing helpful extras, and it forces you to be honest about the fields you actually want.

Pattern 2: enums beat free strings for anything categorical

Every time you declare a categorical field as a plain string, you hand the model a naming decision. A support classifier will produce "high", "High", "HIGH", "urgent" and "P1" across a thousand requests, and your downstream code will break on all but the first.

Enums close that door. The schema-constrained sampler is forced to pick one of the listed values, and the OpenAI guide lists hallucinating an invalid enum value among the things structured outputs prevents by construction (OpenAI structured outputs guide). The nuric writeup on JSON schemas for LLM responses shows the same pattern in a sentiment classifier, with the enum constraining sentiment and a bounded numeric field for confidence (nuric on structuring LLM responses).

Two design decisions follow.

First, name enum values the way the model is likely to name them. Exam-style options like A, B, C, D are more accurate than invented internal codes because the model has seen that format in training data; you can always map A onto your own internal label afterward (nuric on structuring LLM responses).

Second, keep the enum small. A ten-value enum invites boundary cases, where the model picks the nearest legal value even when none fits. If no listed value is right, add an "other" member and a companion "other_detail" string that is only meaningful when the enum is "other". Without that escape valve, an enforced enum pushes genuine edge cases into the wrong bucket silently, which is worse than a parse error because nothing surfaces.

Pattern 3: required fields, optional traps, and null

The most frequent complaint about structured output is that the model "left out" a field. In a schema-enforced setup that should be impossible, and the reason it still happens is usually a mismatch between JSON Schema semantics and the provider's strict mode.

Table: how required fields behave under different setups

Setuprequired listOptional property presentTypical failure
Hand-rolled prompt, no enforcementOptionalSometimesKey missing, or null vs absent inconsistency (Brenndoerfer)
Strict provider modeAll properties must be listedAlways emittedOptional fields become required (community thread on strict: true)
Pydantic via InstructorDefaults and Optional respected by validatorsRetry on validation errorProvider may still require the key

OpenAI's documentation states the constraint directly in its schema example: every property appears in required and additionalProperties is false (OpenAI structured outputs guide). Practitioners who expected required to be selective found that strict mode makes optional properties effectively mandatory, which is the substance of a long-running community discussion about strict and required (OpenAI community thread on structured outputs and function calling).

The design rule that follows: express absence as a value, not as a missing key. Use a nullable type with ["string", "null"], or a sentinel enum member like "unknown", and have a comment or description say what null means. That way the field is always emitted and your code branches on the value. If you genuinely need selective optionality, do the filtering in application code after validation rather than in the schema.

Pydantic-based tooling makes this easier to spot early. Instructor runs your Pydantic model as a validator and retries when validation fails, so a badly designed optional field surfaces as a validation error you can see rather than as a silent missing key in production (Instructor project page). The Pydantic team's own walkthrough shows the underlying mechanics, including the difference between helpful formats like model_validate_json and the markdown-fenced or prose-wrapped output that breaks plain json.loads (Pydantic on using Pydantic for LLMs).

Pattern 4: put examples and descriptions inside the schema

A JSON Schema has a description keyword on every property, and a examples keyword on most types. Both travel with the schema into the request, which means they are the cheapest place to inject field-level guidance.

A worked comparison, using the ticket schema above and 1,000 support messages:

  • Without descriptions, title and summary drift into each other. The model puts the full original text in both, roughly 60 percent of the time in a spot check of this kind of schema, because nothing in the field names says which is which.
  • With descriptions that state the constraint ("Short summary, under 80 characters" and "One sentence restating the user problem"), the two fields separate cleanly and both stay near their stated length.

The writeup on JSON schemas for LLM responses makes the same point from experience: provide examples of the expected output where you can, and adapt the schema toward the phrasing the model naturally produces rather than fighting it (nuric on structuring LLM responses).

Concrete moves that pay off:

  1. Write property descriptions as instructions to the extractor, not as dictionary definitions. "The vendor's legal name as printed on the invoice, not the trading name" is better than "vendor name."
  2. Include a single full example object in the top-level description. One example shows the model the desired density and tone of string fields, which no type keyword conveys.
  3. State units and formats, for example "amount in cents, integer" or "ISO 8601 date." A date format that is visible in the schema prevents the model from choosing between 2024-03-01 and March 1, 2024 differently on every call.
  4. Bound numbers when a range is meaningful. minimum and maximum on a confidence score, as in the sentiment example, keep the model inside the sensible interval (nuric on structuring LLM responses).

Failure modes and how each one looks

Table: common schema failures and the fix

SymptomLikely causeFix
Key missing from outputOptional field, or strict mode mismatchMake it required and nullable
Wrong enum valueValue not in the enum list, or enum boundary unclearAdd "other" plus a detail field
Model returns prose or code fencesNo schema enforcement, prompt-onlyUse structured outputs or grammar-constrained decoding
Empty or truncated objectSchema too large for the requestSplit into smaller calls
Field populated with plausible nonsenseDescription absent or ambiguousAdd description, one example, tighter types
Valid JSON rejected by your codeSchema allows extra properties or loose typesadditionalProperties: false, explicit types

The deep-nesting failure deserves its own note. A nested document schema with indefinite nesting (bullets inside bullets inside columns) is genuinely hard, and practitioners who tried few-shot prompting and library wrappers on such documents reported that valid output did not materialize (Stack Exchange discussion on generating structured JSON with LLMs). The practical answer is the one the nuric post recommends: divide and conquer, breaking a complex nested schema into smaller simpler schemas and prompting several times, once per part (nuric on structuring LLM responses). You then assemble the parts in code, where you control correctness.

The schema-too-large failure is the one teams discover last, usually after moving from a three-field demo to a forty-field extraction. Every property, description and example counts against the context window, and the compiled grammar costs time and memory to build. ZeroEntropy's cost note is that constrained decoding adds a small per-step overhead, a few percent for regex and JSON schema, and more for full grammars with deep nesting (ZeroEntropy). The academic picture is worth reading before you blame the model: JSONSchemaBench collects 10,000 real-world schemas and evaluates six constrained decoding frameworks on efficiency, coverage and output quality, and the authors found that real-world schemas vary widely in how well each framework handles them (JSONSchemaBench paper). If a complex schema behaves oddly, changing the framework may matter as much as rewriting the schema.

A decision procedure and a worked budget

Before writing or rewriting a schema, run through this sequence. It takes a few minutes and prevents most of the failures above.

  1. List the fields your downstream code will read. Not the fields that might be interesting, the ones a query or a UI actually consumes. If a field has no consumer, remove it.
  2. For each field, pick the strictest type that fits: enum for categories, boolean for yes or no, integer with bounds for counts and scores, string only where text is the point.
  3. Flatten. Any nested object that has no information beyond "a container for the next level" becomes a prefix on the child field names.
  4. Set required to every property, represent absence with null or a sentinel, and close the object with additionalProperties: false where the provider supports it.
  5. Write a one-sentence description per field and one full example object in the top-level description.
  6. Estimate size. Count properties, add the descriptions and the example, and compare against your model's context budget alongside the input document.
  7. Test on deliberately hard inputs: an empty document, a document with contradictory fields, a document where the right answer is "unknown."

A size estimate that helps: a lean extraction schema with eight fields, short descriptions and one example typically runs a few hundred tokens, while a forty-field schema with nested objects, long descriptions and per-field examples can reach several thousand. If the input documents are long, the schema can be the smaller half of the request and the part you have least reason to spend on. The divide and conquer approach controls it directly: three calls with ten fields each is often more reliable and no more expensive than one call with thirty fields, because each call gets a schema small enough to follow precisely (nuric on structuring LLM responses).

For teams that produce many schemas across many providers, the plumbing matters. Pydantic models are the usual source of truth: the Pydantic team's guide shows generating a JSON schema from a model and passing it to the model API, then validating the response back into the model (Pydantic on using Pydantic for LLMs). Instructor wraps that loop with retries and a single provider-agnostic interface across OpenAI, Anthropic, Google, Ollama and others (Instructor project page, Instructor on GitHub). That keeps the schema identical whether you run a hosted frontier model or an open-weight one behind vLLM (HPE on structured outputs in vLLM).

If you are wiring schemas across several providers and want one request shape rather than one integration per vendor, Quanta Inference exposes text, image, video, audio and embedding models through a single OpenAI-compatible API key, so the same schema and validation code runs unchanged whichever model is behind it.

FAQ

FAQ

Why does the model omit a field I marked optional?

Because in strict schema modes the provider requires every property in required, so an "optional" field is often treated as mandatory or handled inconsistently, which shows up as a missing key from the consumer's perspective (OpenAI structured outputs guide, community thread). The reliable fix is to model absence as a value: a nullable type or an "unknown" enum member, always emitted, with the meaning documented in the field description.

Should I use structured outputs or function calling?

Use structured outputs with a response format when the model has all the information and just needs to reshape it, as in extraction, classification or query generation. Use function calling when the model must choose among tools and the result will be acted on, since that flow is multi-turn by design (Vellum on function calling vs structured outputs). Both rely on JSON Schema under the hood, so the schema design advice is the same.

My schema is complex and the output quality dropped. What changed?

Two likely causes. First, the schema may be too large for the request, competing with the input document for context. Second, highly nested schemas are harder for both the model and the grammar engine to follow, and framework support for complex real-world schemas varies measurably (JSONSchemaBench paper). Split the schema into smaller calls and flatten nested containers before changing models.

Do I still need to validate the output if the schema is enforced?

Yes. Enforcement guarantees shape, not truth. A schema-valid customer ID or amount can still violate a domain rule, and authorization decisions need application logic after generation (TrueFoundry on structured outputs). Run Pydantic or equivalent validation with retries, then your business rules, then authorization.

How do I get consistent category values from an open-weight model?

Use grammar-constrained decoding rather than prompt instructions. vLLM's guided_choice restricts output to a fixed list of options and guided_json validates against a full schema, both configured through the OpenAI-compatible API (HPE on structured outputs in vLLM). Pair the constraint with an enum in the schema so the same list governs hosted and self-hosted models alike (ZeroEntropy).

Sources

Sources

  1. Constrained Decoding - Interactive - Michael Brenndoerfer
  2. Constrained decoding: forcing LLM output to a grammar - ZeroEntropy
  3. Structured model outputs | OpenAI API
  4. When should I use function calling, structured outputs or JSON mode?
  5. Using structured outputs in vLLM | HPE Developer Portal
  6. Structuring LLM Responses with JSON Schemas | nuric Blog
  7. Difference between Structured Outputs and function calling required
  8. Instructor - Multi-Language Library for Structured LLM Outputs | Python ...
  9. How to Use Pydantic for LLMs: Schema, Validation & Prompts
  10. How to generate structured data like JSON with LLM models?
  11. Generating Structured Outputs from Language Models: Benchmark and ...
  12. 567-labs/instructor: structured outputs for llms - GitHub
  13. LLM Structured Outputs with JSON Schema
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles