Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 4 из 10 Средний 45 мин 100 XP

Модели Pydantic: тело запроса и автоматическая валидация

Описываем данные моделями Pydantic: автоконверсия типов, правила Field, вложенные модели — и валидируем тело POST-запроса FastAPI.

Редакция Питоники

Клиент присылает в ваш POST-запрос тело: {"name": "Эспрессо", "price": "дорого"}. Цену — словом. Дальше по классике: вы пишете if not isinstance(price, float), добавляете проверку имени, потом ещё пять полей, и через месяц код валидации в три раза длиннее бизнес-логики. Pydantic — библиотека, встроенная в FastAPI, — делает эту работу за вас: вы один раз описываете данные классом, а библиотека принимает, конвертирует и отбраковывает данные сама.

У этого урока есть особенность: в отличие от HTTP-частей курса, pydantic полностью работает в нашем интерактивном редакторе. Каждый блок с пометкой «запускается» можно исполнить прямо на странице — и вживую увидеть, как модель принимает данные или выбрасывает ValidationError с русскими строками внутри. Ломайте примеры, меняйте значения — так валидация запоминается за один вечер.

Как работает валидация в Pydantic?

BaseModel: описание данных в три строки

Модель pydantic — это класс, унаследованный от BaseModel, с полями, у которых есть аннотации типов. Именно по этим аннотациям работает вся магия. Описываем товар:

Первая модель pydantic — запускается в браузере
from pydantic import BaseModel

class Product(BaseModel):
    name: str
    price: float
    in_stock: bool = True

# Данные, как будто пришли в теле POST-запроса
p = Product(name="Эспрессо", price="199.9", in_stock="yes")
print(p)
print(p.name, p.price, p.in_stock)
Вывод
name='Эспрессо' price=199.9 in_stock=True
Эспрессо 199.9 True

Вглядитесь в вывод. Мы передали price строкой — в модели лежит число 199.9. Мы передали in_stock строкой "yes" — модель вернула True. А поле in_stock с дефолтом можно было вообще не указывать. Это и есть pydantic: автоконверсия типов по аннотациям плюс значения по умолчанию. Обратиться к полям можно как к обычным атрибутам: p.name, p.price — без словарных скобок.

Кстати, конверсия работает только на совместимые значения: 199.9 становится числом, потому что это честная запись дроби. А вот слово «дорого» числом быть не может — сейчас увидим, что произойдёт.

ValidationError: щит, а не раздражение

Если данные не подходят под аннотации, pydantic выбрасывает ValidationError — и это лучшее, что может случиться с вашим API. Лучше упасть на границе, чем пропустить мусор в базу. Поймаем ошибку и разберём её структуру — блок запускается в браузере:

Ловим ValidationError и читаем нарушения
from pydantic import BaseModel, ValidationError

class Product(BaseModel):
    name: str
    price: float

try:
    # name передали числом, price - словом
    Product(name=123, price="дорого")
except ValidationError as e:
    print("нарушений:", len(e.errors()))
    for err in e.errors():
        field = ".".join(str(x) for x in err["loc"])
        print(f"{field}: {err['msg']} [{err['type']}]")
Вывод
нарушений: 2
name: Input should be a valid string [string_type]
price: Input should be a valid number, unable to parse string as a number [float_parsing]

У каждого нарушения три ключевых части, вы их уже видели в уроке про 422: loc — какое поле сломалось (для вложенных моделей путь через точку), msg — человеческое описание, type — машинный код нарушения. Pydantic собрал оба нарушения сразу, а не упал на первом — так клиенту удобнее исправить всё за один запрос. Именно этот список FastAPI упакует в JSON со статусом 422.

Field: правила для каждого поля

Типы — это полбеды. Цена должна быть больше нуля. Имя — не пустое и не на три экрана. Количество — неотрицательное. Эти содержательные правила задаются через Field, и каждое из них pydantic проверит автоматически. Запустите и посмотрите, как модель ругается сразу на три нарушения:

Field: границы значений — запускается в браузере
from pydantic import BaseModel, Field, ValidationError

class Product(BaseModel):
    name: str = Field(min_length=2, max_length=50)
    price: float = Field(gt=0)          # строго больше нуля
    quantity: int = Field(default=0, ge=0)  # не меньше нуля

try:
    Product(name="К", price=-5, quantity=-1)
except ValidationError as e:
    for err in e.errors():
        field = ".".join(str(x) for x in err["loc"])
        print(f"{field}: {err['msg']}")
Вывод
name: String should have at least 2 characters
price: Input should be greater than 0
quantity: Input should be greater than or equal to 0

Рабочий набор Field: для чисел — gt, ge, lt, le; для строк — min_length, max_length, pattern (регулярное выражение, например для email); для коллекций — min_items, max_items. У Field есть и аргумент description — он попадает в документацию Swagger, так что описание поля вы пишете один раз, а видят его и программисты, и документация.

Поля по умолчанию: default и default_factory

Дефолтное значение — это поле: тип = значение. Но для изменяемых или уникальных значений есть тонкость: если поставить id: str = "abc", у всех задач будет одинаковый id. Для «генерируй при создании» существует default_factory — функция, которая вызывается для каждого нового объекта. Классический приём — генерировать короткий идентификатор через uuid:

default_factory: уникальный id на каждый объект
from pydantic import BaseModel, Field
import uuid

class Task(BaseModel):
    id: str = Field(default_factory=lambda: uuid.uuid4().hex[:8])
    title: str
    done: bool = False

t1 = Task(title="Созвон в 15:00")
t2 = Task(title="Купить кофе", done=True)
print(t1.done, t2.done)
print("id разные:", t1.id != t2.id)
print("длина id:", len(t1.id))
Вывод
False True
id разные: True
длина id: 8

Этот же приём — основа мини-CRUD из следующего урока: POST-ручка будет принимать модель без id, а id присвоит default_factory. Заметьте, done: bool = False сработал как обычный дефолт, потому что False неизменяемый, — default_factory нужен только там, где значение должно создаваться заново каждый раз.

Вложенные модели: заказ внутри заказа

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

Вложенные модели и путь ошибки — запускается в браузере
from pydantic import BaseModel, ValidationError

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

class User(BaseModel):
    name: str
    age: int
    address: Address

# Вложенный словарь стал вложенной моделью
u = User(name="Оля", age=25,
         address={"city": "Москва", "street": "Тверская"})
print(u.address.city, u.address.street)

# В адресе не хватает улицы
try:
    User(name="Оля", age=25, address={"city": "Москва"})
except ValidationError as e:
    for err in e.errors():
        field = ".".join(str(x) for x in err["loc"])
        print(f"{field}: {err['msg']}")
Вывод
Москва Тверская
address.street: Field required

Смотрите на loc ошибки: address.street — pydantic указал точный путь к сломавшемуся полю через всю вложенность. В JSON-ответе 422 от FastAPI тот же путь придёт списком из трёх меток: body, address, street. Для глубокой вложенности — список позиций заказа с пятью полями — это спасение: вы сразу знаете, в какой из тридцати позиций проблема.

Лишние поля: тихая потеря данных

А теперь поведение, которое удивляет почти всех. Клиент передал поле с опечаткой — statys вместо status. Что сделает модель по умолчанию? Ничего. Лишние поля pydantic молча выбрасывает, и запрос считается успешным — данные потеряны без всякой ошибки:

extra=forbid: лишнее поле — это ошибка
from pydantic import BaseModel, ConfigDict, ValidationError

class Registration(BaseModel):
    model_config = ConfigDict(extra="forbid")

    status: str

try:
    # statys - опечатка, pydantic считает это лишним полем
    Registration(status="ok", statys="опечатка")
except ValidationError as e:
    for err in e.errors():
        field = ".".join(str(x) for x in err["loc"])
        print(f"{field}: {err['msg']}")
Вывод
statys: Extra inputs are not permitted

Три варианта поведения: extra="ignore" (по умолчанию — выбросить молча), extra="forbid" (ошибка) и extra="allow" (сохранить). Для публичных API, где данные пишутся в базу, forbid — моя дефолтная рекомендация: лучше явный 422, чем тихая потеря поля.

Как это выглядит в FastAPI: тело POST-запроса

Теперь соединим модель с FastAPI. Объявите параметр функции с типом-моделью — и FastAPI сам прочитает тело запроса, распарсит JSON и прогонит через валидацию ещё до входа в вашу функцию:

main.py — POST с телом-моделью
from fastapi import FastAPI
from pydantic import BaseModel, Field

class ProductIn(BaseModel):
    name: str = Field(min_length=2)
    price: float = Field(gt=0)

app = FastAPI()

@app.post("/products")
def create_product(product: ProductIn):
    # product - уже проверенный объект, а не словарь
    return {"created": product.name, "price": product.price}
Вывод
# POST /products с телом {"name": "Латте", "price": 250}
{"created": "Латте", "price": 250.0}

# POST /products с битым телом {"name": "Л", "price": "дорого"}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "string_too_short",
      "loc": ["body", "name"],
      "msg": "String should have at least 2 characters",
      "input": "Л"
    },
    {
      "type": "float_parsing",
      "loc": ["body", "price"],
      "msg": "Input should be a valid number, unable to parse string as a number",
      "input": "дорого"
    }
  ]
}
Сервер в браузере не запустить — это пример реального кода FastAPI с ожидаемым ответом. Но саму валидацию вы уже прожили в runnable-блоках выше.

Разница с ручной валидацией радикальная: ноль строк проверок внутри функции, а в ответе 422 — точные пути к полям. Имена loc начинаются с body, потому что сломались поля тела, а не query — FastAPI различает источники данных. Схема модели при этом автоматически появилась в /docs: фронтендер увидит, какие поля обязательны и какие у них ограничения.

model_dump: модель превращается в словарь и JSON

Модель — это не только приём данных, но и их отдача. Чтобы модель записать в базу (словарь) или вернуть клиенту (JSON), есть два метода: model_dump() и model_dump_json(). Запустите:

model_dump и model_dump_json — запускается в браузере
from pydantic import BaseModel

class Product(BaseModel):
    name: str
    price: float

p = Product(name="Латте", price=250)

as_dict = p.model_dump()
print(type(as_dict).__name__, as_dict["price"])

as_json = p.model_dump_json()
print(as_json)
Вывод
dict 250.0
{"name":"Латте","price":250.0}

Обратите внимание на две детали. model_dump() отдал обычный dict — его можно положить в список хранилища (как в пятом уроке) или передать дальше по коду. А model_dump_json() напечатал 250.0, хотя мы ввели 250: pydantic честно держит поле как float, и JSON отражает это. Если клиент придирчиво проверяет типы — теперь вы знаете, откуда ноль.

Модель ответа: response_model фильтрует лишнее

Модель можно повесить не только на вход, но и на выход — параметр response_model у декоратора. FastAPI прогонит ваш возврат через указанную модель и отдаст клиенту только поля из неё. Зачем? Затем, что внутренние структуры обычно жирнее публичного ответа: хэш пароля, служебные флаги, внутренние id. Одно объявление — и секреты физически не попадут в JSON:

main.py — response_model скрывает пароль
from fastapi import FastAPI
from pydantic import BaseModel

class UserIn(BaseModel):
    name: str
    password: str

class UserOut(BaseModel):
    name: str

app = FastAPI()

@app.post("/users", response_model=UserOut)
def create_user(user: UserIn):
    # password есть внутри функции,
    # но в ответ уйдёт только то, что есть в UserOut
    return user
Вывод
# POST /users с телом {"name": "Оля", "password": "secret123"}
{"name": "Оля"}
Сервер в браузере не запустить — это пример реального кода FastAPI: пароль не покидает сервер.

Это фундаментальный паттерн «модель входа + модель выхода», и в шестом уроке мы развернём его полностью: фильтрация полей, разные схемы для разных ролей, заголовки и статусы ответа. Здесь важно понять принцип: pydantic описывает контракт данных с обеих сторон границы.

Pydantic v1 против v2: почему str больше не принимает число

Если вы читали старые туториалы, могли увидеть: Item(name=123) — и поле name магически стало строкой "123". Так вел себя pydantic первой версии. Во второй версии (2023 год, именно она в FastAPI сейчас) правила ужесточили: str принимает только строки. Проверьте:

v2: число в str-поле — ошибка
from pydantic import BaseModel, ValidationError

class Item(BaseModel):
    name: str

try:
    # В pydantic v1 стало бы "123", в v2 - ValidationError
    Item(name=123)
except ValidationError as e:
    err = e.errors()[0]
    print(err["msg"], "|", err["type"])
Вывод
Input should be a valid string | string_type

Полигон: попробуйте сломать модель сами

Все runnable-блоки этого урока — ваша песочница. Что стоит попробовать прямо сейчас, прямо в редакторе на странице: добавьте в модель Product поле tags: list[str] и передайте строку вместо списка; поставьте price: float = Field(gt=100) и проверьте цену 99; передайте в Address улицу числом. Каждый раз вы получите ValidationError с точным loc — и это главный навык урока: читать нарушения, а не бояться их.

И финальный чек: модель pydantic в FastAPI-ручке — это входной контракт (body), фильтр (response_model) и документация (/docs) одновременно. Три артефакта из одного класса — редкая экономия в индустрии, где контракт обычно ведут в трёх местах и развозит в трёх же. Глубже в валидацию — отдельный гайд по pydantic: Field-ограничения и кастомные валидаторы с примерами.

Итоги урока

  • BaseModel с аннотациями — описание данных; pydantic конвертирует совместимые значения сам: строка "199.9" становится числом.
  • ValidationError собирает все нарушения сразу, у каждого — loc, msg, type; FastAPI превращает его в ответ 422.
  • Field(gt=..., min_length=..., pattern=...) задаёт содержательные правила; default_factory генерирует уникальные значения при создании.
  • Вложенные модели валидируются рекурсивно, loc показывает путь через точку: address.street.
  • Лишние поля по умолчанию игнорируются молча; ConfigDict(extra="forbid") превращает их в ошибку.
  • model_dump() и model_dump_json() выводят модель в словарь и JSON; response_model фильтрует ответ на выходе.

У нас всё готово для настоящего сервиса: маршруты из второго урока, параметры из третьего, модели из четвёртого. В пятом уроке соберём из этого полный CRUD — создание, чтение, обновление и удаление задач — с честными статусами 201, 404 и 204. Первый настоящий бэкенд, который можно показать на собеседовании. Дальше по плану — база данных, тесты и JWT; расписание всех десяти уроков — самоучитель FastAPI.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

from pydantic import BaseModel

class Product(BaseModel):
    name: str
    price: float
    in_stock: bool = True

p = Product(name="Латте", price="250")
print(p.in_stock, p.price + 0.5)
from pydantic import BaseModel, ValidationError

class Item(BaseModel):
    title: str
    count: int

try:
    Item(title=42, count="abc")
except ValidationError as e:
    for err in e.errors():
        print(err["type"])
Проверь себя
0 / 5

1. Что произойдёт при Product(name="Кофе", price="199.9"), если price: float?

2. Сколько нарушений соберёт ValidationError, если сломаны два поля модели?

3. Клиент передал поле statys вместо status, у модели включён extra="forbid". Что будет?

4. Зачем нужен Field(default_factory=...)?

5. Что делает response_model=UserOut в декораторе @app.post("/users")?

Карточки терминов
Запомнено: 0 / 6
Практика

Соберите модель регистрации User: username — строка минимум 3 символа, age — целое от 14 до 120, email — строка с обязательной «собачкой» (pattern). Создайте валидного пользователя olga, 25 лет, olga@mail.ru и напечатайте имя с возрастом. Затем попробуйте создать пользователя с коротким именем «o» и возрастом 200 — поймайте ValidationError и напечатайте каждое нарушение в формате «поле: сообщение».

practice.py
Вопросы и ответы по уроку

Что такое pydantic в FastAPI простыми словами?

Это библиотека валидации данных: вы описываете структуру классом с аннотациями типов (BaseModel), а pydantic сам проверяет и конвертирует приходящие данные. FastAPI использует его для тел запросов: битые данные отбраковываются со статусом 422 до выполнения вашей функции.

Чем pydantic v2 отличается от v1?

Вторая версия (2023) переписана на Rust — валидация в разы быстрее, но правила строже: int больше не превращается в str молча. Методы переименованы: .dict() -> model_dump(), .json() -> model_dump_json(). Старые туториалы с .dict() описывают v1.

Как валидировать вложенные объекты в pydantic?

Вложите модель в модель: у класса Order поле items: list[Item] — и каждый элемент списка будет провалидирован правилами Item. Ошибка внутри вложенности получит путь loc через точку или список, например body.address.street — сразу видно, какое именно поле виновато.

Как сделать поле модели необязательным?

Дайте ему значение по умолчанию: comment: str = "". Для «может отсутствовать» используйте comment: str | None = None. Поле без дефолта обязательно — при его отсутствии pydantic вернёт нарушение Field required.

Почему pydantic выдаёт ошибку "Input should be a valid string"?

Так ведёт себя pydantic v2: поле со строковой аннотацией принимает только строки — число 123 молча строкой больше не становится (в v1 было бы "123"). Передайте значение строкой или поменяйте тип поля. Бонус-подсказка: методы .dict() и .json() из старых туториалов тоже признак v1 — во второй версии это model_dump() и model_dump_json().

Понравился урок? Сошлитесь на него

«Молча проглотить нарушение — значит отдать клиенту данные, которых не было.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.

Проверьте знания по FastAPI

В челлендже — 20 задач по FastAPI, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по FastAPI: 20 задач