Вложенные модели, списки и опциональные поля

Pydantic валидирует данные любой вложенности: модель может содержать другие модели, списки объектов, словари и опциональные поля — проверка идёт рекурсивно.

Реальные данные редко плоские. Заказ содержит список позиций, у пользователя есть адрес-объект, у товара — теги-список. Pydantic проверяет такие структуры на любую глубину автоматически.

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

from pydantic import BaseModel

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

class OrderItem(BaseModel):
    product_id: int
    quantity: int = 1

class Order(BaseModel):
    customer: str
    address: Address                 # вложенная модель
    items: list[OrderItem]           # список моделей
    comment: str | None = None       # опциональное поле

Запрос с таким телом FastAPI разберёт целиком: проверит customer, рекурсивно — address как Address, каждый элемент items как OrderItem, а comment допустит отсутствующим. Ошибка в любой ветке (например, отрицательное quantity, если бы стояло ограничение) вернётся с точным указанием пути до проблемного поля.

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

Рекурсивная валидация — это обход дерева: для каждого поля смотрим тип; если это модель — спускаемся внутрь, если список моделей — проверяем каждый элемент. Смоделируем рекурсивный валидатор на stdlib:

def validate(value, spec, path="root"):
    errors = []
    if isinstance(spec, dict):                  # вложенный объект
        if not isinstance(value, dict):
            return [f"{path}: ожидался объект"]
        for key, subspec in spec.items():
            if key not in value:
                errors.append(f"{path}.{key}: поле отсутствует")
            else:
                errors += validate(value[key], subspec, f"{path}.{key}")
    elif isinstance(spec, list):                # список объектов
        for i, item in enumerate(value):
            errors += validate(item, spec[0], f"{path}[{i}]")
    else:                                        # простой тип
        if not isinstance(value, spec):
            errors.append(f"{path}: ожидался {spec.__name__}")
    return errors

order_spec = {
    "customer": str,
    "address": {"city": str, "zip_code": str},
    "items": [{"product_id": int, "quantity": int}],
}
good = {"customer": "Аня", "address": {"city": "Москва", "zip_code": "101000"},
        "items": [{"product_id": 1, "quantity": 2}]}
bad = {"customer": "Боб", "address": {"city": "Казань"},
       "items": [{"product_id": "x", "quantity": 1}]}
print("good:", validate(good, order_spec))
print("bad :", validate(bad, order_spec))

Попробуй сам ▶ Видно, что ошибки указывают точный путь: root.address.zip_code, root.items[0].product_id — так же подробно сообщает Pydantic.

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

Первая — описывать вложенный объект как dict вместо отдельной модели, теряя валидацию его внутренностей. Вторая — путать list[Item] (список объектов) и Item (один объект). Третья — делать поле опциональным через str | None, но забывать дефолт = None, из-за чего оно остаётся обязательным (тип допускает None, но значение всё равно требуется прислать). Четвёртая — чрезмерная вложенность; иногда структуру стоит уплостить ради простоты API.

Best practices

  • Каждую вложенную сущность описывайте отдельной моделью, а не dict.
  • Списки объектов — list[Model]; FastAPI провалидирует каждый элемент.
  • Для опционального поля указывайте и тип | None, и значение по умолчанию = None.
  • Ошибки валидации содержат путь до поля — используйте его при отладке.

Рекурсивные и обобщённые структуры

Вложенность может быть не только конечной, но и рекурсивной: дерево комментариев, где у комментария есть список ответов того же типа. Pydantic поддерживает такие самоссылающиеся модели — поле объявляют типом самой модели в строковой форме или через отложенные аннотации, и валидация корректно обходит дерево любой глубины. Другой мощный приём — обобщённые модели-обёртки: единый формат ответа Page[T] с полями items, total, page, где T — тип элемента. Так вы описываете пагинацию один раз и переиспользуете для любого ресурса. Эти возможности показывают, что система типов Pydantic — не игрушечная: она выражает реальные структуры данных, от деревьев до обобщённых контейнеров, и проверяет их так же строго, как простые поля, всегда указывая точный путь до проблемы при ошибке.

Итог: Pydantic валидирует структуры любой вложенности рекурсивно, проверяя каждую модель и каждый элемент списка, и сообщает точный путь до ошибочного поля. Описывайте вложенные сущности отдельными моделями.

Проверьте себя
1. Как FastAPI провалидирует поле items: list[OrderItem] в теле запроса?
AПроверит только длину списка
BПроверит каждый элемент списка как модель OrderItem (рекурсивно)
CОставит список без проверки
DПревратит список в строку
2. Что нужно, чтобы поле comment было по-настоящему опциональным (можно не присылать)?
AТолько тип str | None
BИ тип str | None, и значение по умолчанию = None
CТолько значение по умолчанию
DАннотация Optional без дефолта