Вложенные и обобщённые модели

Реальные данные — это деревья: заказ со списком позиций, ответ API с обёрткой и полезной нагрузкой. Pydantic собирает такие структуры из вложенных, списочных, обобщённых и размеченных моделей.

Вложенная модель — это поле модели, тип которого сам является моделью Pydantic. Так из простых моделей собираются сложные: Pydantic рекурсивно проверяет каждый уровень и превращает вложенные словари в объекты нужных классов.

Плоские модели (несколько полей-примитивов) встречаются редко. Чаще данные иерархичны: у пользователя есть адрес (объект), у заказа — список позиций (список объектов), ответ API завёрнут в конверт { data, meta }. Вручную разбирать такие словари — это километры проверок «есть ли ключ, тот ли тип». Pydantic делает это декларативно: вы описываете форму данных типами, а проверку и сборку берёт на себя библиотека.

Этот урок — про вложенные модели и списки моделей, про обобщённые (generic) модели, которые параметризуются типом полезной нагрузки, и про дискриминированные union, где нужный класс выбирается по значению специального поля-тега.

Зачем это на практике

  • Сложные тела запросов: заказ с массивом позиций, профиль с вложенным адресом — FastAPI разберёт и проверит всё дерево разом и отдаст 422 с точным путём до ошибки (например, items.0.price).
  • Единый конверт ответа: один тип Response[T] с полями data и meta на все эндпоинты — без копирования модели обёртки под каждый ресурс.
  • Полиморфные события: входящее сообщение может быть разных видов (payment, refund, shipment), и по полю-тегу нужно выбрать правильную модель — это дискриминированный union.
  • Документация даром: вся вложенная структура автоматически попадает в OpenAPI-схему, поэтому Swagger показывает реальную форму запросов и ответов.

Код использует from pydantic import ... и в браузере не исполняется (кнопки «Запустить» нет) — это образцы. Идею выбора класса по тегу мы продублируем на чистом Python.

Вложенные модели и списки моделей

Чтобы вложить одну модель в другую, достаточно указать её как тип поля. Список однотипных объектов — это list[Model]. Pydantic примет на входе обычные словари и сам построит из них объекты нужных классов.

from pydantic import BaseModel

class Address(BaseModel):
    city: str
    zip: str

class Item(BaseModel):
    title: str
    price: float

class Order(BaseModel):
    address: Address          # вложенная модель
    items: list[Item]         # список моделей

order = Order(
    address={"city": "Казань", "zip": "420000"},
    items=[{"title": "Книга", "price": 500}, {"title": "Ручка", "price": 50}],
)

print(order.address.city)      # Казань
print(order.items[0].title)    # Книга
print(len(order.items))        # 2

Обратите внимание: на входе мы передали обычные dict, а на выходе order.address — это полноценный объект Address (с проверенными полями), а order.items — список объектов Item. Проверка рекурсивна: если у любой позиции price окажется нечислом, ошибка укажет точный путь items.N.price. Так строятся деревья любой глубины — модель внутри модели внутри списка моделей.

Обобщённые (generic) модели

Часто форма обёртки одинакова, а «начинка» разная: ответ { data, total }, где data — то список пользователей, то один товар. Копировать обёртку под каждый тип не нужно — её делают обобщённой через typing.Generic и TypeVar.

from typing import Generic, TypeVar
from pydantic import BaseModel

T = TypeVar("T")

class Page(BaseModel, Generic[T]):
    items: list[T]
    total: int

class User(BaseModel):
    id: int
    name: str

# параметризуем обёртку конкретным типом
page = Page[User](
    items=[{"id": 1, "name": "Анна"}, {"id": 2, "name": "Олег"}],
    total=2,
)

print(page.total)            # 2
print(page.items[0].name)    # Анна
print(type(page.items[0]))   # <class '__main__.User'>

Здесь Page[User] — это конкретизация обобщённой модели: Pydantic знает, что items — это list[User], и проверяет элементы как User. Та же Page с другим параметром (Page[Item]) переиспользуется без единой новой строки. Это типичный приём для единого конверта ответа на весь API.

Дискриминированные union по полю-тегу

Бывает, что поле может содержать объект одного из нескольких типов, и какой именно — подсказывает специальное поле-тег (часто называется type или kind). Можно было бы написать просто Cat | Dog, но тогда Pydantic стал бы перебирать варианты по очереди и подбирать подходящий — медленно и с непонятными ошибками. Лучше явно указать поле-дискриминатор: тогда выбор класса делается по одному значению — быстро и с точной диагностикой.

from typing import Literal, Annotated, Union
from pydantic import BaseModel, Field

class Cat(BaseModel):
    kind: Literal["cat"]
    meow_volume: int

class Dog(BaseModel):
    kind: Literal["dog"]
    bark_volume: int

Animal = Annotated[Union[Cat, Dog], Field(discriminator="kind")]

class Shelter(BaseModel):
    pet: Animal

s1 = Shelter(pet={"kind": "cat", "meow_volume": 5})
print(type(s1.pet).__name__)   # Cat

s2 = Shelter(pet={"kind": "dog", "bark_volume": 9})
print(type(s2.pet).__name__)   # Dog

Поле kind с типом Literal["cat"] / Literal["dog"] — это и есть тег. Параметр Field(discriminator="kind") говорит Pydantic: смотри на kind и сразу бери нужный класс. Если придёт {"kind": "bird"}, ошибка будет понятной — «нет варианта с таким тегом», а не каша из попыток подобрать модель. Сама логика «по тегу выбрать обработчик» элементарна и на чистом Python:

def parse(payload):
    kind = payload["kind"]
    if kind == "cat":
        return f"Кот по имени {payload['name']}, мяукает"
    if kind == "dog":
        return f"Пёс по имени {payload['name']}, лает"
    raise ValueError(f"неизвестный тег: {kind}")

print(parse({"kind": "cat", "name": "Барсик"}))
print(parse({"kind": "dog", "name": "Рекс"}))

Вывод:

Кот по имени Барсик, мяукает
Пёс по имени Рекс, лает

Дискриминированный union — это та же идея «развилка по полю-тегу», но Pydantic делает её типобезопасно, быстро и с понятными ошибками, да ещё и отражает варианты в OpenAPI-схеме.

Как это работает под капотом

Для вложенных структур Pydantic строит рекурсивную core schema: схема внешней модели ссылается на схемы вложенных, те — на свои вложенные, и так далее. Поэтому при разборе библиотека спускается по дереву ровно один раз, на каждом узле применяя нужный валидатор, и собирает готовые объекты снизу вверх. Путь до ошибки (items.0.price) формируется автоматически из этого обхода.

Generic-модели Pydantic кеширует по параметру: Page[User] и Page[Item] — это две отдельные построенные схемы, созданные по одному «шаблону», но дальше работающие независимо и быстро. Для дискриминированного union ядро строит отображение «значение тега → схема варианта», поэтому выбор нужной модели — это не перебор, а прямой поиск по тегу: одна проверка вместо последовательных попыток. Отсюда и скорость, и аккуратные сообщения об ошибках.

Частые ошибки

  • Ждут dict, а получают объект. После разбора вложенное поле — это объект модели (order.address.city), а не словарь (order.address["city"]). Чтобы снова получить словари, есть model_dump().
  • Forward reference без обновления. Если модель ссылается на саму себя или на класс, объявленный ниже, иногда нужен Model.model_rebuild(), иначе ссылка не разрешится.
  • Generic без Generic[T]. Класс-обёртка должен наследоваться от Generic[T] и использовать TypeVar; просто написать T в аннотации недостаточно.
  • Union без дискриминатора. Голый Cat | Dog заставляет Pydantic перебирать варианты и даёт мутные ошибки. Для полиморфизма по тегу добавляйте Field(discriminator="...").
  • Тег не Literal. Поле-дискриминатор должно быть Literal["cat"] с конкретным значением, а не просто str — иначе Pydantic не сможет однозначно сопоставить вариант.

Итоги

  • Вложенная модель — это поле типа другой модели; список объектов — list[Model]. Pydantic рекурсивно проверяет дерево и строит объекты из словарей.
  • После разбора вложенные поля становятся объектами моделей, а не словарями; вернуть словари можно через model_dump().
  • Обобщённые модели (Generic[T] + TypeVar) дают переиспользуемую обёртку: один Page[T] на разные начинки.
  • Дискриминированный union (Field(discriminator="kind") + поля Literal[...]) выбирает класс по тегу — быстро и с точными ошибками, в отличие от голого union.
  • Вся вложенная структура автоматически отражается в OpenAPI-схеме, давая правдивую документацию.
Проверьте себя
1. После разбора Order(address={"city": "Казань", ...}) чем является order.address?
AОбъектом модели Address — обращение идёт через атрибут: order.address.city
BОбычным словарём — обращаться нужно через order.address["city"]
CСтрокой с JSON
DNone, пока не вызвать model_dump()
2. Зачем для поля, которое может быть Cat или Dog, использовать Field(discriminator="kind") вместо голого Cat | Dog?
AПо значению тега kind класс выбирается напрямую — это быстрее перебора вариантов и даёт понятные ошибки
BГолый union вообще не работает в Pydantic v2
Cdiscriminator шифрует поле kind
DЭто нужно только для списков, а не для одиночных полей
3. Что обязательно для обобщённой модели-обёртки Page[T]?
AУнаследовать класс от Generic[T] и объявить T через TypeVar
BДостаточно написать T в аннотации поля без всякого наследования
CСоздавать отдельный класс Page под каждый тип вручную
DПометить поле декоратором @generic