Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Validating Nested Request and Response Data with Pydantic Models

Hello! Last time, you gave handbook passages typed fields and kept their original page numbers attached to their text. The remaining gap is at the boundary: a type annotation on a dataclass does not check whether a client actually sent a valid page number.

In this lesson, you’ll use Pydantic models to validate a nested request containing passages and an outgoing answer containing citations. You’ll inspect failures at specific locations in the data, then serialize a valid response. Allow about 40–45 minutes for the videos, reading, and code.


Turn a data shape into a checked contract

A Pydantic model resembles the dataclass you just built: it has annotated, named fields. The important difference here is that Pydantic checks data when you construct or validate the model. Watch Corey Schafer’s Python Pydantic Tutorial for the basic model syntax and, later in the same video, an example of one model containing others.

Python Pydantic Tutorial: Complete Data Validation Course (Used by FastAPI)

Corey Schafer’s Python Pydantic Tutorial demonstrates both BaseModel and recursive validation. Watch these two segments before building the handbook schemas.

Watch basic models for required fields and fields with defaults. Then skip to nested models; focus on what happens when a model field contains another model or a list of models.

Our previous internal Passage had flat fields: source_id, page, and text. For data arriving from outside the application, let’s group the location into a source object. An answer can then use that same source shape for its citations.

Create schemas_demo.py in your project and add:

from pydantic import BaseModel, Field, ValidationError


class SourceRef(BaseModel):
    source_id: str = Field(min_length=1)
    page: int = Field(ge=1)


class PassageInput(BaseModel):
    source: SourceRef
    text: str = Field(min_length=1)


class AnswerRequest(BaseModel):
    question: str = Field(min_length=1)
    passages: list[PassageInput]


class AnswerResponse(BaseModel):
    answer: str = Field(min_length=1)
    citations: list[SourceRef]

Read the types from the inside out. SourceRef requires a nonempty source ID and a page number of at least one. A PassageInput requires a SourceRef and nonempty text. An AnswerRequest requires a question and a list whose elements must each satisfy PassageInput. Finally, every citation in an AnswerResponse must satisfy SourceRef.

These are shape and value constraints, not factual checks. A citation with source_id="handbook" and page=99 can pass validation even if the handbook has no page 99. Likewise, min_length=1 rejects an empty string but does not reject whitespace-only text. Validation establishes what the program can safely assume about a data structure; it does not establish that an answer is grounded in evidence.

For a concise reference alongside the video, read the Pydantic documentation’s basic and nested examples:

Models | Pydantic Docs

Pydantic’s Models documentation shows what model construction validates and how nested objects become model instances.

Read the opening of “Models,” from model definition through the explanation of untrusted data. In “Basic model usage,” examine the User definition, then read from “The model can then be instantiated” through the model_dump() discussion, stopping before “By default, models are mutable.” In “Nested models,” start at nested model types and follow the Foo, Bar, and Spam example through its model_dump() output. Notice that nested dictionaries become nested model objects, then become dictionaries again when dumped.


Validate a nested incoming request

Add a sample payload beneath the models. It represents two retained handbook passages, still labeled pages 1 and 3 after the blank page was discarded:

raw_request = {
    "question": "What is the annual leave allowance?",
    "passages": [
        {
            "source": {"source_id": "handbook", "page": 1},
            "text": "Annual leave is 20 days.",
        },
        {
            "source": {"source_id": "handbook", "page": "3"},
            "text": "Carry over 5 days.",
        },
    ],
}

request = AnswerRequest.model_validate(raw_request)
print(request.passages[1].source.page)        # 3
print(type(request.passages[1].source.page))  # <class 'int'>

Run uv add pydantic if Pydantic is not yet a project dependency, then run uv run python schemas_demo.py.

model_validate() accepts the Python dictionary and returns an AnswerRequest. Its passages are PassageInput instances, and their sources are SourceRef instances—not unchecked dictionaries tucked inside a checked outer shell.

Notice the "3" in the incoming data. By default, Pydantic can coerce a numeric string to an integer, so the resulting page is 3. This is useful when conversion is acceptable, but it is a policy choice. If your boundary must reject "3" and accept only an integer, you can call AnswerRequest.model_validate(raw_request, strict=True) instead; that call would reject this sample payload.

The request model deliberately differs from the internal Passage dataclass. After validation, application code can explicitly map passage.source.source_id, passage.source.page, and passage.text into that internal record. Keeping the boundary model separate lets an external JSON layout change without silently redefining every internal object.


Use validation errors to locate bad data

Pydantic reports where validation failed, including positions inside lists. Read its validation and error-handling reference, then add a deliberately faulty passage to the example.

Models | Pydantic Docs

Return to Pydantic’s Models documentation for the methods used to accept data and the structure of validation failures.

In “Validating data,” begin at validation modes. Read the model_validate() and model_validate_json() bullets and the example through its first caught ValidationError; you can stop before the model_validate_strings() examples. Then, in “Error handling,” read from its first sentence through the demonstration with two failing fields, stopping before “Arbitrary class instances.” Pay attention to multiple errors.

Append this code:

from copy import deepcopy

bad_request = deepcopy(raw_request)
bad_request["passages"][1]["source"]["page"] = "unknown"
bad_request["passages"][1]["text"] = ""

try:
    AnswerRequest.model_validate(bad_request)
except ValidationError as exc:
    for error in exc.errors():
        print(error["loc"], error["type"])

The two reported locations will identify the second passage’s page and text:

('passages', 1, 'source', 'page') int_parsing
('passages', 1, 'text') string_too_short

The 1 is a zero-based list index; it points to the second passage. Pydantic gathers both failures rather than making you repair the page before discovering the empty text. In a running service, be careful about logging complete validation errors indiscriminately: error details can include rejected input values, and passage text might be sensitive. The location and error type often suffice for diagnosis.

By default, Pydantic ignores unexpected fields. That may be convenient for a flexible input format, but if an unrecognized field should make a request fail, configure the relevant models with ConfigDict(extra="forbid"). Apply that rule to nested models as well if you want unknown keys rejected throughout the request—not just at its top level.


Check what leaves the application

Input validation protects code that consumes a request. Response validation checks a different boundary: whether your application is about to send the structure it promised. This matters for AI features because assembled answers and citations may come from several processing stages, any of which can produce incomplete data.

Watch a short portion of Corey Schafer’s Python FastAPI Tutorial (Part 4). Its example uses blog posts rather than passages, but it makes the distinction between request and response schemas concrete.

Python FastAPI Tutorial (Part 4): Pydantic Schemas - Request and Response Validation

Corey Schafer’s FastAPI tutorial shows where separate input and output schemas fit in an API. You do not need to build the API shown in the video for this lesson.

Watch response schemas for the output contract and request schemas for validation of an incoming body. Focus on why the returned data needs a contract even after the request has passed validation.

You can apply both checks without running a web server. Add the following to schemas_demo.py:

raw_response = {
    "answer": "Annual leave is 20 days.",
    "citations": [{"source_id": "handbook", "page": 1}],
}

response = AnswerResponse.model_validate(raw_response)
print(response.citations[0].page)  # 1
print(response.model_dump())
print(response.model_dump_json())

model_dump() produces a Python dictionary with nested dictionaries and lists. model_dump_json() produces a JSON string. Validating before serializing means the outgoing structure has been checked against the declared response model.

Now inspect an incomplete outgoing citation:

bad_response = {
    "answer": "Annual leave is 20 days.",
    "citations": [{"source_id": "handbook"}],
}

try:
    AnswerResponse.model_validate(bad_response)
except ValidationError as exc:
    for error in exc.errors():
        print(error["loc"], error["type"])

The location ('citations', 0, 'page') identifies the missing page in the first citation. This is not a client’s invalid request; it is a defect in the data our application produced. In a future FastAPI endpoint, a typed request body and a declared response model will put these contracts at the HTTP boundary. For now, validating them directly makes each contract—and its failures—easy to see.


Takeaway

A Pydantic BaseModel turns annotated fields into a runtime-checked data contract. Model fields can contain other models or lists of models, so one validation call checks a request all the way down to a passage’s page number. ValidationError locations tell you where malformed data occurred; a separate response model checks the structure you send back. Neither model can, by itself, determine whether an answer or citation is true.

Next, you’ll handle API failures and resource cleanup with targeted exceptions and context managers—building on the distinction between invalid boundary data and failures that occur while the application does its work.

Can't find a good explanation? Sign up and we'll make it for you

Sign up