Модели Pydantic: тело запроса и автоматическая валидация
Описываем данные моделями Pydantic: автоконверсия типов, правила Field, вложенные модели — и валидируем тело POST-запроса FastAPI.
Редакция Питоники
Клиент присылает в ваш POST-запрос тело: {"name": "Эспрессо", "price": "дорого"}. Цену — словом. Дальше по классике: вы пишете if not isinstance(price, float), добавляете проверку имени, потом ещё пять полей, и через месяц код валидации в три раза длиннее бизнес-логики. Pydantic — библиотека, встроенная в FastAPI, — делает эту работу за вас: вы один раз описываете данные классом, а библиотека принимает, конвертирует и отбраковывает данные сама.
У этого урока есть особенность: в отличие от HTTP-частей курса, pydantic полностью работает в нашем интерактивном редакторе. Каждый блок с пометкой «запускается» можно исполнить прямо на странице — и вживую увидеть, как модель принимает данные или выбрасывает ValidationError с русскими строками внутри. Ломайте примеры, меняйте значения — так валидация запоминается за один вечер.
Как работает валидация в Pydantic?
BaseModel: описание данных в три строки
Модель pydantic — это класс, унаследованный от BaseModel, с полями, у которых есть аннотации типов. Именно по этим аннотациям работает вся магия. Описываем товар:
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. Лучше упасть на границе, чем пропустить мусор в базу. Поймаем ошибку и разберём её структуру — блок запускается в браузере:
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 проверит автоматически. Запустите и посмотрите, как модель ругается сразу на три нарушения:
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:
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 молча выбрасывает, и запрос считается успешным — данные потеряны без всякой ошибки:
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 и прогонит через валидацию ещё до входа в вашу функцию:
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": "дорого"
}
]
}Разница с ручной валидацией радикальная: ноль строк проверок внутри функции, а в ответе 422 — точные пути к полям. Имена loc начинаются с body, потому что сломались поля тела, а не query — FastAPI различает источники данных. Схема модели при этом автоматически появилась в /docs: фронтендер увидит, какие поля обязательны и какие у них ограничения.
model_dump: модель превращается в словарь и JSON
Модель — это не только приём данных, но и их отдача. Чтобы модель записать в базу (словарь) или вернуть клиенту (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:
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": "Оля"}Это фундаментальный паттерн «модель входа + модель выхода», и в шестом уроке мы развернём его полностью: фильтрация полей, разные схемы для разных ролей, заголовки и статусы ответа. Здесь важно понять принцип: pydantic описывает контракт данных с обеих сторон границы.
Pydantic v1 против v2: почему str больше не принимает число
Если вы читали старые туториалы, могли увидеть: Item(name=123) — и поле name магически стало строкой "123". Так вел себя pydantic первой версии. Во второй версии (2023 год, именно она в FastAPI сейчас) правила ужесточили: 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"])
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")?
Соберите модель регистрации User: username — строка минимум 3 символа, age — целое от 14 до 120, email — строка с обязательной «собачкой» (pattern). Создайте валидного пользователя olga, 25 лет, olga@mail.ru и напечатайте имя с возрастом. Затем попробуйте создать пользователя с коротким именем «o» и возрастом 200 — поймайте ValidationError и напечатайте каждое нарушение в формате «поле: сообщение».
Что такое 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
FastAPI · Урок 3
Параметры запросов: path, query и валидация в FastAPI
Осваиваем path- и query-параметры FastAPI: типы, дефолты, валидация через Query и чтение ошибки 422.
FastAPI · Урок 5
CRUD на FastAPI: POST, PUT, PATCH и DELETE методы
Строим полный CRUD на FastAPI: POST со статусом 201, PUT с защитой от 404, PATCH и DELETE — и собираем мини-сервис задач целиком.
FastAPI · Урок 6
Ответы сервера: response_model, статусы и заголовки
response_model фильтрует поля ответа, HTTPException отдаёт внятные ошибки, заголовок Location ведёт к созданному ресурсу — договор сервера с клиентом.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 14
Валидация в Flask API: плохие запросы и коды 400
Четырнадцатый урок курса Flask: API из урока 13 падает на первом же кривом запросе. Добавляем валидацию — честные 400 с понятным текстом ошибки, 404 для отсутствующих записей и тесты на плохие входы через test_client.
flask api валидация 400валидация тела запроса flask
NumPy · Урок 1
Что такое NumPy и как установить через pip: первый массив ndarray
Первый массив ndarray: создаём, сравниваем со списком, разбираем dtype и shape — и ускоряем сумму миллиона чисел примерно в сто раз.
что такое numpyчто такое numpy простыми словами
FastAPI · Урок 7
Подключаем базу данных SQLite к FastAPI
Словарь задач из урока 5 умирает при перезапуске. Ставим на его место SQLite через SQLAlchemy: движок, сессии, Depends и CRUD, который переживает uvicorn --reload.
fastapi база данныхsqlalchemy fastapi
Проверьте знания по FastAPI
В челлендже — 20 задач по FastAPI, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по FastAPI: 20 задач