bigonotes
AIPrompting

Structured Output

Three levels of getting machine-readable output from a model, and why you still validate every response in code.

3 min readLast updated

Most real applications need model output that code can read, not prose for a person. You can ask for that structure with three levels of strength, and each one guarantees more than the last. None of them guarantees that the values are right, so validation stays your job.

Three levels of control#

The levels differ in what the provider promises and in how a response can still go wrong.

Level What is guaranteed What can still go wrong
Prompt-only Nothing. You ask for JSON and hope. Markdown backticks around the output, extra fields, or JSON that looks valid but has the wrong structure.
JSON mode The output parses as valid JSON. The JSON may not match your schema: missing fields, wrong types, unexpected keys.
Schema-constrained The output matches your exact schema. The values can still be wrong.

Prompt-only output works some of the time and breaks unpredictably. In JSON mode the provider promises the text will never be malformed, but it makes no promise about shape.

With schema-constrained output, the provider restricts decoding. At each step the model can only pick tokens that keep the output valid against your schema, so the structure is guaranteed.

Valid is not correct#

A response can match your schema perfectly and still contain the wrong date or the wrong number. The schema checks the shape of the answer, not its truth. That is why you validate in code whichever level you use: Pydantic in Python, Zod in TypeScript.

When validation fails, send the error back to the model and retry once or twice. After that, fail gracefully instead of looping. The usual retry rules apply: cap the number of tries, because every attempt costs a request.

from pydantic import BaseModel, ValidationError

class Invoice(BaseModel):
    vendor: str
    total: float
    due_date: str | None = None  # null is an honest "not in the text"

def extract_invoice(messages, max_tries=3):
    for _ in range(max_tries):
        raw = call_model(messages)
        try:
            return Invoice.model_validate_json(raw)
        except ValidationError as err:
            # Show the model exactly what it got wrong.
            messages = messages + [{"role": "user", "content": str(err)}]
    return None  # out of tries: let the caller degrade

Here call_model stands for whatever function sends the messages and returns the reply text. The due_date field allows null on purpose. If the field were required, a document with no due date would push the model to invent one.

Trade-offs and field order#

Schema-constrained output has a cost of its own. Constraining decoding means the model sometimes cannot produce its most natural token and must take the next best one that fits. On complex schemas this can slightly reduce quality.

The model also writes fields left to right, in the order they appear in the schema, which is the same token-by-token generation as plain text. So field order is reasoning order. Put a reasoning or evidence field before the answer field, and the model works through the problem first and then commits.

{
  "reasoning": "The email mentions a refund and a broken item.",
  "category": "refund_request"
}

If category came first, the model would have to commit to a label before it had written any reasoning. This is the same idea as step-by-step prompting, applied inside a schema.

Streaming and structure#

Structured output and streaming pull in different directions. A half-finished JSON string cannot be parsed, so you normally wait for the whole response before you validate it.

Short line#

Ask for structure at the strongest level your provider offers, put reasoning fields before answer fields, and always validate in code. A schema guarantees shape, never truth.

Key takeaways#

  • The three levels are prompt-only, JSON mode and schema-constrained. Each guarantees more than the one before.
  • Valid JSON that matches your schema can still hold wrong values.
  • Validate with Pydantic or Zod. On failure, send the error back and retry once or twice, then fail gracefully.
  • Allow null for optional fields so the model has an honest alternative to inventing a value.
  • Schema-constrained decoding can slightly reduce quality on complex schemas.
  • Field order is reasoning order: put reasoning before the answer.
Revision notes · 1 min

Core idea#

Machine-readable output comes in three levels of strength. None of them guarantees correct values, so you validate in code.

The three levels#

Level Guaranteed Can still go wrong
Prompt-only Nothing Backticks, extra fields, wrong structure
JSON mode Valid JSON Does not match your schema
Schema-constrained Matches your schema Values can be wrong

Key points#

  • Schema-constrained decoding only allows tokens that keep the output valid.
  • Validate with Pydantic (Python) or Zod (TypeScript).
  • On failure, send the error back to the model. Retry once or twice.
  • After the retries, fail gracefully.
  • Allow null for optional fields. The model then has an honest option.
  • The model writes fields in schema order. Put a reasoning field before the answer field.
  • You cannot parse JSON mid-stream. Wait for the full response.

Pitfalls#

  • Do not trust valid JSON to hold correct values.
  • Do not make optional fields required. The model invents values.
  • Do not retry without limit.
  • Expect slightly lower quality on complex schemas with constrained decoding.

Short line#

  • "A schema guarantees shape, never truth. Put reasoning first, and validate in code."

Found a mistake? Email ask@bigonotes.com