Lesson 1: Validated Outputs
The problem this lesson solves
The recipe-extractor pipeline now asks a model to pull structured data out of each recipe page: the title, the ingredient list with quantities, the serving count, and the total time. You ask for JSON. You get this:
Here's the extracted recipe data:
```json
{
"title": "Spaghetti Carbonara",
"servings": "4-6",
"ingredients": [
{"name": "spaghetti", "quantity": 400, "unit": "g"},
{"name": "guanciale", "quantity": 150, "unit": "g"},
]
}
Let me know if you need anything else!
Your `json.loads` raises on the first character. Four separate things are wrong: a conversational preamble, a markdown fence, a trailing comma after the last ingredient, and a `servings` value that is a string range where your schema promised an integer.
Module 5 called this a model error, the category where every other layer reports success. The status was 200. No exception was raised by the HTTP client. The retry logic saw nothing to retry. The failure is entirely in the content, and the only thing that finds it is validation.
Module 2 established the principle this lesson applies: **the model's output is untrusted input**. Validate it exactly as you would validate a form submission from a stranger, because in the ways that matter it is one.
### Pydantic for model outputs
Module 2 introduced Pydantic as the validating cousin of the dataclass and used it for settings. This is the more important use.
```python
from pydantic import BaseModel, Field
class Ingredient(BaseModel):
name: str = Field(min_length=1, max_length=200)
quantity: float | None = Field(default=None, ge=0)
unit: str | None = None
class ExtractedRecipe(BaseModel):
title: str = Field(min_length=1, max_length=300)
servings: int | None = Field(default=None, ge=1, le=100)
total_minutes: int | None = Field(default=None, ge=0, le=10_000)
ingredients: list[Ingredient] = Field(min_length=1, max_length=100)
Two things this gives you that a dataclass cannot. It checks the types and constraints of data that arrived while the program was running, which is the only kind of checking that helps here. And it produces a JSON schema you can send to the model, so the description the model receives and the validation your code performs come from one definition rather than two that drift apart.
schema = ExtractedRecipe.model_json_schema()
That schema is what goes into the request. When you change the model class, the prompt changes with it automatically, which removes an entire class of bug where the prompt asks for a field the code no longer expects.
Validating a response:
recipe = ExtractedRecipe.model_validate_json(raw_text) # from a JSON string
recipe = ExtractedRecipe.model_validate(payload) # from a parsed dict
Both raise ValidationError on failure, and the error names every field that failed and why. That detail matters more than it appears, because it is what the repair loop in Lesson 2 sends back to the model.
Schema design: required, optional, enumerated, bounded
A schema is a specification you are giving to two audiences at once: your code, which enforces it, and the model, which tries to satisfy it. Design decisions affect both.
Required versus optional. Make a field required only when the answer genuinely always exists. A required field the model cannot fill forces it to invent something, because the schema says a value must be there.
class ExtractedRecipe(BaseModel):
title: str # every recipe has one
servings: int | None = None # many pages do not state this
total_minutes: int | None = None # nor this
Making servings required does not produce more servings data. It produces fabricated servings data, which is worse than a null, because a null is honest and a guess is not. Optional fields are how you let a model tell you it does not know.
Enumerated values close a vocabulary so the model cannot invent a category.
from typing import Literal
MeasurementUnit = Literal["g", "kg", "ml", "l", "tsp", "tbsp", "cup", "piece", "pinch"]
class Ingredient(BaseModel):
name: str
quantity: float | None = None
unit: MeasurementUnit | None = None
Without this, you receive "g", "gram", "grams", "gr", and "G" across a corpus, and normalising them afterwards is a job you have created for yourself. With it, anything outside the list fails validation and can be repaired.
Include an escape hatch when the vocabulary might genuinely be incomplete. A "other" member plus a free-text field is better than forcing a wrong choice.
Bounded values catch nonsense that is structurally valid.
servings: int | None = Field(default=None, ge=1, le=100)
total_minutes: int | None = Field(default=None, ge=0, le=10_000)
ingredients: list[Ingredient] = Field(min_length=1, max_length=100)
A recipe serving 50,000 people is a parsing failure, not a recipe. Bounds turn that from data you store into an error you can see. Note max_length on the list as well, which prevents a runaway generation from producing ten thousand ingredients and consuming your memory.
Field constraints, validators, and custom coercion
Constraints handle simple rules. Validators handle rules that need code.
from pydantic import BaseModel, Field, field_validator, model_validator
class ExtractedRecipe(BaseModel):
title: str = Field(min_length=1, max_length=300)
servings: int | None = Field(default=None, ge=1, le=100)
prep_minutes: int | None = Field(default=None, ge=0)
cook_minutes: int | None = Field(default=None, ge=0)
total_minutes: int | None = Field(default=None, ge=0)
@field_validator("title")
@classmethod
def clean_title(cls, value: str) -> str:
"""Collapse whitespace and strip common scraped suffixes."""
cleaned = " ".join(value.split())
for suffix in (" | Recipe", " - Recipes", " Recipe"):
if cleaned.endswith(suffix):
cleaned = cleaned[: -len(suffix)]
return cleaned.strip()
@model_validator(mode="after")
def check_time_consistency(self) -> "ExtractedRecipe":
"""Total time must not be less than its parts."""
if self.total_minutes is None:
return self
parts = [t for t in (self.prep_minutes, self.cook_minutes) if t is not None]
if parts and self.total_minutes < sum(parts):
raise ValueError(
f"total_minutes ({self.total_minutes}) is less than "
f"prep plus cook ({sum(parts)})"
)
return self
field_validator sees one field. model_validator(mode="after") sees the whole object once every field has been validated individually, which is what you need for rules involving more than one field.
Custom coercion handles the "4-6" problem from the opening example. Rather than failing on input the model reasonably produced, convert it.
@field_validator("servings", mode="before")
@classmethod
def parse_servings(cls, value: object) -> object:
"""Accept '4', '4-6', 'serves 4', and return the lower bound."""
if not isinstance(value, str):
return value
match = re.search(r"\d+", value)
return int(match.group()) if match else None
mode="before" runs ahead of type checking, so it receives the raw value and can reshape it. Without it, the string never reaches your code because validation has already rejected it.
Coerce carefully. Every coercion is a decision that a wrong-looking value should be accepted, and each one hides a signal about model behaviour. Coerce formatting variation such as a numeric string. Do not coerce meaning: if the model returns "about four", turning that into 4 invents a precision that was not there. When in doubt, fail and repair rather than coerce.
Schema size and its cost
A schema is not free. It occupies tokens in every request, and it affects accuracy in a way that is not obvious.
The token cost is per call. A schema with forty fields, each with a description, can run to a thousand tokens or more. Across a two million document corpus that is two billion input tokens spent on the same repeated text. Prompt caching reduces this considerably where the schema sits in a stable prefix, which is why the ordering advice from Module 3 matters here.
The accuracy cost is less intuitive. A larger schema does not linearly reduce quality, but past a certain size, models get worse at filling every field correctly. Deep nesting is harder than flat structure. Fields with ambiguous names or overlapping meanings get confused with each other. Long enum lists get approximated.
Three practical consequences.
Extract what you need, not everything you might want. A twelve-field schema you use beats a forty-field schema where twenty-eight fields are ignored downstream.
Prefer flat structures. Two levels of nesting is comfortable, four is not. If your schema is deeply nested, consider whether two calls with simpler schemas would be more reliable than one call with a complex one.
Split by concern when a schema grows. Extracting recipe metadata and extracting nutritional analysis are different tasks. One call each, with a focused schema, is usually both cheaper and more accurate than one call trying to do both, despite being two requests.
Use field descriptions where the name is not enough, and nowhere else.
class ExtractedRecipe(BaseModel):
title: str = Field(description="The recipe name, excluding site branding")
servings: int | None = Field(
default=None,
description="Number of people served. Null if the page does not state it.",
)
ingredients: list[Ingredient] # needs no description
Descriptions are the most effective way to improve extraction quality and also the fastest way to inflate a schema. Add them where the model is getting something wrong, not preemptively for every field.
Concept check. Your extraction schema marks cuisine as a required field with an enum of twenty cuisines. Accuracy on that field is poor and the model often picks a plausible but wrong value. What are two changes worth trying?
Answer
Make it optional. A required field forces the model to choose even when the page gives no indication of cuisine, and a forced choice from twenty options will often be wrong. An optional field lets it return null, and a null is a correct answer for a page that does not say.
Add an "other" or "unspecified" member to the enum, or a short description defining what counts as evidence for a cuisine. The second is often the higher-value change: if the model is inferring cuisine from a single ingredient, saying explicitly that it should only be set when the page states it removes the guessing.
A third option worth considering is whether the field belongs in this call at all. Classification from the full text may be a better separate call than a field tacked onto an extraction schema.