CoursePython · Structured Outputs, Tool Calling, and Agent Security · part 32 of 79
Part 32 · Structured Outputs, Tool Calling, and Agent Security

Lesson 1: Validated Outputs

8 min read·9 Sept 2026

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:

text
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!

text

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.

python
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:

python
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.

python
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.

python
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.

python
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.

python
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.

python
    @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.

python
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.