Lesson 3 - Nested and Complex Models

Welcome to Nested and Complex Models

So far your Task model has been flat: a handful of fields, each a single string, number, or boolean. Real tasks are richer than that. A task might carry a list of tags like ["work", "q3"], a list of subtasks each with its own title and done-flag, and a due date that needs to be a real point in time, not just any old text. Flat fields can’t capture that. In this lesson you’ll learn to model data that has shape inside it — lists, models within models, and types beyond str/int/bool — and you’ll see Pydantic validate the whole structure, all the way down.

The big idea: nesting doesn’t cost you any safety. A model inside a model is checked just as strictly as a top-level field, and when something deep inside is wrong, the error tells you exactly where.

By the end of this lesson, you will be able to:

  • Declare a field that holds a list of simple values, like tags: list[str]
  • Nest one Pydantic model inside another and have it validated to any depth
  • Use richer field types such as datetime and optional | None fields
  • Produce JSON-ready output with model_dump(mode="json")

You’ll keep building the Task Manager from the previous lessons. Let’s add some depth.


Lists of Simple Values

Sometimes a single field needs to hold several values of the same kind. A task can have many tags. You express “a list of strings” with the type hint list[str]:

from pydantic import BaseModel

class Task(BaseModel):
    title: str
    tags: list[str] = []

tags: list[str] = [] says two things. The type list[str] means the field must be a list, and every item in it must be a string. The default = [] (an empty list) makes the field optional: a request that sends no tags gets an empty list instead of an error. You can do the same with other element types — list[int] for a list of numbers, list[bool] for a list of booleans — but list[str] is the most common for things like tags and labels.

Pydantic doesn’t just check that the value is a list; it checks every element. Send tags: ["work", 5] and the 5 would be flagged, because 5 is not a string. The container and its contents are validated.


Nesting a Model Inside a Model

Tags are a list of plain strings. Subtasks are different: each subtask is itself a little structured thing with its own title and done fields. To model that, you first define a model for the small thing, then use it as the type of a field in the bigger model.

from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Subtask(BaseModel):
    title: str
    done: bool = False

class Task(BaseModel):
    title: str
    tags: list[str] = []
    subtasks: list[Subtask] = []
    due: datetime | None = None

@app.post("/tasks")
def create(task: Task):
    return task.model_dump(mode="json")

Look at subtasks: list[Subtask] = []. The element type isn’t a plain string this time — it’s another model, Subtask. So this field means “a list, where each item is a valid Subtask.” A Subtask in turn requires a title and has an optional done that defaults to False. We’ve defined Subtask before Task so that Task can refer to it.

Let’s send a full task — with tags, two subtasks, and a due date — and see what comes back. We’ll use FastAPI’s TestClient to call the endpoint from code, just as in earlier lessons (no running server needed):

from fastapi.testclient import TestClient

client = TestClient(app)

body = {
    "title": "Launch",
    "tags": ["work", "q3"],
    "subtasks": [{"title": "Draft"}, {"title": "Review", "done": True}],
    "due": "2026-07-01T17:00:00",
}
print(client.post("/tasks", json=body).json())
{'title': 'Launch', 'tags': ['work', 'q3'], 'subtasks': [{'title': 'Draft', 'done': False}, {'title': 'Review', 'done': True}], 'due': '2026-07-01T17:00:00'}

Notice what happened. The subtasks list came back with two items, exactly as sent. The first subtask only supplied a title, so its done filled in with the default False — the same defaulting you saw on flat models, now happening one level deep. Each subtask was parsed into a proper Subtask and validated on its own.

Validation Goes All the Way Down

Here’s the payoff of nesting with Pydantic: a subtask is checked just as strictly as a top-level field. Subtask requires a title, so a subtask without one is invalid — even though it’s buried inside a list inside the body. Watch what happens when we send a subtask that’s missing its title:

import json

r = client.post("/tasks", json={"title": "X", "subtasks": [{"done": True}]})
print(r.status_code)
print(json.dumps(r.json(), indent=2))
422
{
  "detail": [
    {
      "type": "missing",
      "loc": [
        "body",
        "subtasks",
        0,
        "title"
      ],
      "msg": "Field required",
      "input": {
        "done": true
      }
    }
  ]
}

The request was rejected with a 422, and your endpoint never ran. Look closely at loc — the “location” of the problem. It reads ["body", "subtasks", 0, "title"], which you can read left to right as a trail: in the body, inside subtasks, at index 0 (the first one), the title field is missing. Pydantic didn’t just say “something’s wrong” — it pointed at the exact field, deep inside the structure. That precision is what makes nested validation actually usable.

Validation reaches every level, and the error shows you where

There’s no depth limit. A model inside a list inside a model is validated just as thoroughly as a top-level field — Pydantic walks the whole structure. When something fails, the error’s loc is a path from the outside in (bodysubtasks0title), so you always know exactly which value to fix. You never have to dig through your data by hand to find the bad piece.


Richer Field Types: Dates and Optionals

Look back at the due field: due: datetime | None = None. There are two new ideas packed in here.

First, the type datetime. JSON has no real date type — a date arrives as a string. Declaring the field as datetime tells Pydantic to parse that string into a true Python datetime object, and to reject it if it isn’t a valid date. The standard text format for dates is ISO 8601, which looks like "2026-07-01T17:00:00" (year-month-day, a T, then the time). When you send that, you get a real datetime you can compare and do arithmetic with — not just a string that happens to look like a date.

Second, | None. The | means “or,” so datetime | None means “a datetime or nothing.” This is how you write an optional value that may be genuinely absent — a task without a due date. Pairing it with the default = None lets a client leave due out entirely and get None back.

Validation applies here too. Send text that isn’t a valid date and Pydantic refuses it:

r = client.post("/tasks", json={"title": "X", "due": "not-a-date"})
print(r.status_code)
print(json.dumps(r.json(), indent=2))
422
{
  "detail": [
    {
      "type": "datetime_from_date_parsing",
      "loc": [
        "body",
        "due"
      ],
      "msg": "Input should be a valid datetime or date, invalid character in year",
      "input": "not-a-date",
      "ctx": {
        "error": "invalid character in year"
      }
    }
  ]
}

The loc points at ["body", "due"] and the message explains that the value couldn’t be parsed as a datetime. datetime is just one of many rich types Pydantic understands — it can also handle UUID (for unique identifiers) and Decimal (for exact decimal numbers like money), among others. The pattern is always the same: declare the precise type you want, and Pydantic parses and validates incoming strings into it for you.


JSON-Ready Output with model_dump(mode=“json”)

There’s one wrinkle with rich types. A parsed datetime is a Python object, not a string — and not everything that consumes your data speaks Python. Compare the two ways of dumping a model:

t = Task(title="Demo", due="2026-07-01T17:00:00")
print("python:", t.model_dump())
print("json:  ", t.model_dump(mode="json"))
python: {'title': 'Demo', 'tags': [], 'subtasks': [], 'due': datetime.datetime(2026, 7, 1, 17, 0)}
json:   {'title': 'Demo', 'tags': [], 'subtasks': [], 'due': '2026-07-01T17:00:00'}

The plain model_dump() keeps due as a real datetime.datetime object — great for working in Python, but a datetime isn’t valid JSON, so you can’t hand that dictionary straight to something expecting JSON. Adding mode="json" converts every value into a JSON-friendly form: the datetime becomes its ISO string "2026-07-01T17:00:00". (The same conversion would turn a UUID or Decimal into a string, too.)

That’s exactly why the endpoint above returned task.model_dump(mode="json"). When you’re producing output destined for JSON, mode="json" saves you from manually converting dates and other rich types one by one. (In the next lesson you’ll see FastAPI do this kind of conversion for you automatically via response models — but it’s good to know what’s happening underneath.)


Practice Exercises

Exercise 1: Read the list type

Given tags: list[str] = [], what does each part mean, and what would the request body {"title": "X", "tags": ["a", 7]} do?

Hint

list[str] means the field is a list whose every item must be a string; the = [] default makes it optional (omit it and you get an empty list). The body {"title": "X", "tags": ["a", 7]} is rejected with a 422, because 7 is not a string — Pydantic validates each element of the list, not just the list itself.

Exercise 2: Predict the nested error location

Using the Task model, you send {"title": "X", "subtasks": [{"title": "Draft"}, {"done": true}]}. Is it accepted? If not, what would the error’s loc be?

Hint

It’s rejected with a 422. The first subtask is fine, but the second (at index 1) is missing its required title. The loc would be ["body", "subtasks", 1, "title"] — the path points to the second item in the list, then the missing title field.

Exercise 3: Optional dates and JSON output

What value does due get when a client omits it, and why? And if due were set, why might you prefer model_dump(mode="json") over plain model_dump() when returning the task?

Hint

With due: datetime | None = None, omitting due gives None — the | None allows the absence and the = None supplies the fallback. You’d prefer model_dump(mode="json") for output because plain model_dump() leaves due as a Python datetime object (not valid JSON), while mode="json" converts it to its ISO string like "2026-07-01T17:00:00".


Summary

Real data has structure inside it, and Pydantic models capture that without giving up any safety. A field typed list[str] holds many values of one kind, with every element validated. A field typed as another model — like subtasks: list[Subtask] — nests one model inside another, and Pydantic validates it all the way down: when something deep inside is wrong, the error’s loc is a path that pinpoints the exact field, such as ["body", "subtasks", 0, "title"]. Rich types like datetime parse strings (in ISO format) into real objects and reject bad input, while | None marks a value that may be genuinely absent. Finally, model_dump(mode="json") converts a model — including its dates and other rich types — into a JSON-ready dictionary.

Key Concepts

  • list[str] — a field holding a list whose every item is validated against the element type.
  • Nested model — a field whose type is another Pydantic model, validated to any depth.
  • loc path — the location trail in a validation error, pointing to the exact offending field.
  • datetime — parses ISO-format date strings into real datetime objects (and rejects invalid ones).
  • | None — marks an optional field that may legitimately be absent.
  • model_dump(mode="json") — produces JSON-serializable output (e.g. datetime → ISO string).

Why This Matters

Almost no real entity is flat. Orders have line items, users have addresses, tasks have subtasks and tags and timestamps. Modeling that structure with nested Pydantic models means you validate the whole shape in one place, and you get errors precise enough to show a user exactly which field to fix — even when it’s buried three levels deep. Rich types and mode="json" close the loop, letting you work with proper Python objects internally while still handing out clean JSON. This is the foundation for the response shaping you’ll do next.


Next Steps

Continue to Lesson 4 - Response Models

Shape what your API sends back: declare a response model so output is filtered, validated, and documented automatically.

Back to Module Overview

Return to the Request Bodies and Pydantic module overview


Continue Building Your Skills

Your models can now describe data of any shape — lists, models within models, dates, and optionals — and validate every piece of it. Next you’ll turn your attention to the other direction: shaping what your API sends back. With response models, you’ll control and document the exact structure of your output, keeping internal details in and presenting clients a clean, predictable response.