Вложенные и обобщённые модели
Реальные данные — это деревья: заказ со списком позиций, ответ 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-схеме, давая правдивую документацию.