Structured output, section 2 of 3, 4h

Designing schemas the model fills well

By the end: You can design a schema that gets accurate values, not just valid JSON, and explain why each choice helps.

  • Field order changes the answer
  • Enums, and the escape hatch every enum needs
  • Nullable versus optional
  • Descriptions are instructions
  • Asking for evidence you can check
  • When one schema should become two calls

New to structured output? Often you do not want prose back, you want data your program can use: named fields with values, of the kind JSON holds. Structured output is how you ask for that and, when the API supports a schema, how you enforce it — you hand over the shape you expect and the model is prevented from producing anything that would not fit, so what arrives always parses. Why it matters

A schema guarantees the shape of what comes back. It guarantees nothing about whether the values are right, and that gap is where the work is. This section is about designing schemas that get accurate answers rather than merely parseable ones: field order that lets the model reason before it commits, an escape hatch on every enum so it is never forced to choose wrongly, descriptions written as instructions because that is what they are, and asking for evidence your code can check. Each change is small, and together they move accuracy more than swapping models does.

Start with the smallest version#

A schema is a promise about shape. Here is the smallest useful one, for sorting support emails, written with Pydantic so the same class validates the result.

from typing import Literal
from pydantic import BaseModel

class Ticket(BaseModel):
    category: Literal["billing", "bug", "account", "other"]
    urgent: bool

Every change below makes this better at producing correct values, which is the part a schema alone does not guarantee.

Order fields the way you would think#

The model writes the JSON from left to right, one token at a time. If the first field is the verdict, it commits to the verdict before writing anything that supports it. Put the evidence first and the conclusion last, and the conclusion is written with the evidence already in view.

class Ticket(BaseModel):
    quote: str          # the sentence that shows what the customer wants
    category: Literal["billing", "bug", "account", "other"]
    urgent: bool

Pydantic keeps your order

Fields are emitted in the order you declare them, and most providers generate in schema order. Check the order in the JSON schema you actually send, not just in your class.

Every enum needs a way out#

If the only choices are billing, bug and account, an email about a job application will be forced into one of them. The model is not allowed to say none of these, so it guesses. Always include an escape value such as 'other' or 'unclear', and then count how often it is used. A rising share of 'other' tells you the categories no longer match reality.

Nullable, not missing#

For extraction, a value that is not in the source should come back as null, never invented. Making the key always present but allowing null is the clearest way to say that, and some strict schema modes require every key to be present anyway, so null is how 'not found' is expressed.

class Invoice(BaseModel):
    invoice_number: str | None   # null when the document has none
    total: float | None
    currency: Literal["EUR", "USD", "GBP"] | None

Descriptions are part of the prompt#

Field descriptions are sent to the model with the schema. Use them for the rules that belong to one field, so the main prompt stays short.

from pydantic import Field

class Invoice(BaseModel):
    total: float | None = Field(
        description="Amount due including tax, as printed. Null if not shown. "
                    "Do not add line items up yourself."
    )

Ask for evidence you can verify in code#

The most useful trick in extraction: for each important value, also ask for the exact text it came from. Then check in code that the text really appears in the document. A value whose quote is not in the source was probably invented.

class Extracted(BaseModel):
    value: str | None
    source_text: str | None

def grounded(f: Extracted, document: str) -> bool:
    if f.value is None:
        return True
    return f.source_text is not None and f.source_text in document

Know when to split#

Large schemas with deep nesting and dozens of fields get worse values than small ones. If a schema has grown past what you could fill in by hand in a minute, consider two calls: one to find the relevant part of the document, one to extract from it. Each can then be tested on its own.

Where people go wrong

Treating a schema that validates as a schema that works. Valid JSON with a confidently wrong value is the failure that reaches users.

If this comes up in an interview

Why does field order in a schema matter?

The model generates the output in order. If the conclusion comes first it is chosen before any supporting text is written. Putting evidence or reasoning fields before the conclusion lets the conclusion depend on them.

Then they push further

Does that mean you should always add a reasoning field?

No. It costs output tokens on every call and is only worth it where the decision is hard. Measure accuracy with and without it on your eval. For simple fields it adds cost and nothing else.

What gets marked up. Marked up for linking it to left-to-right generation and for treating the reasoning field as a cost to be measured, not a free win.

How would you reduce invented values in an extraction pipeline?

Allow null for anything that may be absent and say so in the field description, include an escape value in every enum, and ask for the source text of each value so code can check it appears in the document. Then measure how often values fail that check.

Your classification enum has an 'other' option that is now used 30% of the time. What does that tell you?

That the categories no longer fit the traffic. Read a sample of the 'other' cases, group them, and either add categories or route them differently. It is a useful signal, which is exactly why the escape value exists.

Build this: Take an extraction you have built. Reorder fields so evidence comes first, add an escape value to every enum, add source_text for three key fields with a code check, and compare accuracy before and after on twenty documents.

This is one section of Structured output, 10 hours across 3 sections. Read it in full to get the whole topic in order.

Stop 11 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeStreaming responsesPerceived latency, not real latency.9h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.