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.
{
"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
}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 prompt | Enforced by the provider | |
|---|---|---|
| What you do | Write the schema into the instructions and ask for JSON only | Pass the schema as a parameter on the request |
| Syntax guarantee | None. Commentary, code fences and trailing prose all happen | Valid JSON matching the schema, within the provider's stated limits |
| Typical failure | "Here is the JSON you asked for:" wrapped around the object | Well-formed output with a wrong value in it |
| Portability | Works against any model, including ones you host yourself | Parameter name and schema support vary by provider and version |
| Do you still validate? | Yes | Yes — 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.
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 selfThink 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.
Saved in this browser only.