Skip to main content
ANVISoftware Solutions
Lesson 8 of 22Intermediate16 min

Structured Output

By the end of this lesson

Get schema-conforming output you can validate instead of parsing prose.

Any feature that does something with a model's answer eventually needs to read that answer in code. Pulling an amount out of a sentence with a regular expression works until the phrasing moves, and the phrasing will move. The alternative is to ask for a defined structure and then check that what came back matches it.

Two separate steps, and both are needed. Asking makes the right shape likely. Validating is what lets you rely on it. A prompt is a request, not a constraint, and the same is true of a schema you describe in words.

The shape you want back, written as a schema
JSON
{
  "type": "object",
  "properties": {
    "category": {
      "type": "string",
      "enum": ["travel", "meals", "software", "equipment"]
    },
    "amount_pence": { "type": "integer", "minimum": 0 },
    "receipt_required": { "type": "boolean" },
    "policy_note": { "type": "string", "maxLength": 200 }
  },
  "required": ["category", "amount_pence", "receipt_required", "policy_note"],
  "additionalProperties": false
}
Ask, validate, retry once, then give up
Python
import logging
from typing import Literal
from pydantic import BaseModel, Field, ValidationError

log = logging.getLogger(__name__)

class ClaimFields(BaseModel):
    category: Literal["travel", "meals", "software", "equipment"]
    amount_pence: int = Field(ge=0)
    receipt_required: bool
    policy_note: str = Field(max_length=200)

def extract_claim(description: str) -> ClaimFields | None:
    messages = [
        {"role": "system", "content": SYSTEM_WITH_SCHEMA},
        {"role": "user", "content": f"DESCRIPTION\n{description}"},
    ]

    for attempt in (1, 2):
        raw = call_model(messages, response_format={"type": "json_object"})
        try:
            return ClaimFields.model_validate_json(raw)
        except ValidationError as error:
            log.warning(
                "claim extraction rejected",
                extra={"attempt": attempt, "errors": error.errors()},
            )
            messages.append({"role": "assistant", "content": raw})
            messages.append({
                "role": "user",
                "content": f"That reply was rejected: {error}. Reply with corrected JSON only.",
            })

    return None
  • The Pydantic model is the schema in executable form. Literal restricts category to four values, ge=0 rejects a negative amount, and max_length stops a 4,000-character essay arriving in a field sized for a note.
  • response_format asks the provider to constrain the reply to valid JSON. Where a provider supports a full schema mode, use it — it removes the whole class of "not valid JSON" failures. It says nothing about whether the values are right.
  • model_validate_json parses and checks in one call, and the ValidationError names the field that failed. That specificity is what makes the retry worth attempting.
  • The retry sends the model its own rejected reply plus the validation error, as a new turn rather than by editing the original description. Keeping the correction out of the data block matters: the description is data, the correction is an instruction, and merging them undoes the separation from the previous lesson.
  • Two attempts, then None. A loop that retries until success is how a transient provider problem turns into a large bill. Returning None also forces the caller to decide what happens when there are no usable fields — a review queue, not a guess.

Two ways to get JSON, and what each one actually promises:

 Described in the promptEnforced by the provider
What you doWrite the schema into the instructions and ask for JSON onlyPass the schema as a parameter on the request
Syntax guaranteeNone. Commentary, code fences and trailing prose all happenValid JSON matching the schema, within the provider's stated limits
Typical failure"Here is the JSON you asked for:" wrapped around the objectWell-formed output with a wrong value in it
PortabilityWorks against any model, including ones you host yourselfParameter name and schema support vary by provider and version
Do you still validate?YesYes — for the values, the ranges, and the relationships between fields

Summary

  • Ask for a defined structure instead of parsing prose, then validate what arrives — both steps are required
  • Provider schema modes guarantee form, not meaning; a conforming object can still hold a wrong value
  • Model output is untrusted input: check types, ranges, enums and cross-field rules, and never interpolate it into SQL, shells or markup
  • Retry once with the validation error attached as a new turn, then fail to a defined path such as a review queue
  • A successful parse is not a correct answer, so measure accuracy separately

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Add a field and a cross-field rule

Extend ClaimFields with a vat_pence integer field. Then add a rule that fails validation when receipt_required is false but amount_pence is above 7500, since the fictional policy requires a receipt over 75 pounds.

Feed it a description that should trip the rule and confirm it is rejected rather than accepted.

Show solution

A model validator that runs after the individual fields is the natural place for this, because the rule involves two fields and neither one alone is wrong.

Why bother, when you could correct it afterwards? Because a contradiction is a signal, not just an error. It usually means the model did not have the threshold, or the amount was misread. Failing loudly sends the claim to a human; silently overwriting receipt_required hides a wrong amount behind a tidy record.

This is also the clearest example of validation doing work a schema cannot. JSON Schema and Pydantic field types check each field in isolation. The rules that catch real mistakes are usually about the relationship between fields.

Python
from pydantic import BaseModel, Field, model_validator

RECEIPT_THRESHOLD_PENCE = 7500

class ClaimFields(BaseModel):
    category: Literal["travel", "meals", "software", "equipment"]
    amount_pence: int = Field(ge=0)
    vat_pence: int = Field(ge=0)
    receipt_required: bool
    policy_note: str = Field(max_length=200)

    @model_validator(mode="after")
    def receipt_matches_threshold(self):
        if self.amount_pence > RECEIPT_THRESHOLD_PENCE and not self.receipt_required:
            raise ValueError(
                "amount is above the receipt threshold but receipt_required is false"
            )
        if self.vat_pence > self.amount_pence:
            raise ValueError("vat_pence cannot exceed amount_pence")
        return self

Think about it

What validation cannot tell you

Your extractor returns a perfectly valid object for every claim in a batch of 500. Finance asks whether they can now post these to the ledger without review.

What is your answer, and what would you need in order to change it?

Show solution

No, not on the strength of validation. Every object conforming to the schema means the shape is right 500 times. It says nothing about whether the category or the amount is right even once.

To answer differently you would need a measured error rate: a sample of claims with fields a human has confirmed, compared against the extractor's output, with the disagreements categorised. The evaluation lesson covers how to run that repeatedly so a prompt change can be compared.

Even with a good rate, posting to a ledger is consequential and hard to reverse. A defensible design extracts automatically, posts automatically below a value threshold, and routes the rest for a quick human confirmation. That is a business decision about acceptable error, and it belongs with finance rather than with you.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A provider's schema-enforced output mode gives you which guarantee?
Why should a model's output be treated as untrusted input?

Saved in this browser only.