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

Почему модели ломаются тихо
Pydantic v2 (используется в FastAPI начиная с версии 0.100) переписан на Rust-ядре pydantic-core и работает заметно быстрее предшественника. Но вместе со скоростью пришли новые правила приведения типов, другая логика валидаторов и изменённый порядок вызовов. Если проект мигрировал с Pydantic v1 или если модели разрастались без ревью, ломаться будет в нескольких предсказуемых точках.
Разберём конкретные сценарии: что именно идёт не так, какой ответ получает клиент и как это чинить.
Точка 1. Тихая коерсия типов
По умолчанию Pydantic v2 в «нестрогом» режиме приводит значения к объявленному типу без ошибки. Строка "42" станет числом 42, строка "true" — булевым True, а целое число 1 — тоже True. В Pydantic v1 поведение было похожим, но набор допустимых приведений отличался: например, float в int проходил молча, теперь — только если дробная часть равна нулю.
На практике это означает: клиент отправляет {"count": "five"}, получает 422, а {"count": "5"} — проходит валидацию без единого предупреждения. Если бизнес-логика ожидает строго число из формы, а не «что угодно, что можно привести к int», баг проявится не на этапе парсинга, а позже — в расчётах, в базе, в отчёте.
Как зафиксировать
Включите строгий режим на уровне модели или отдельного поля:
# FastAPI 0.100+, Pydantic v2
from pydantic import BaseModel, ConfigDict, Field
class OrderCreate(BaseModel):
model_config = ConfigDict(strict=True)
count: int
price: float
is_gift: bool
Теперь {"count": "5"} вернёт 422. Если строгий режим нужен не везде, используйте аннотацию на поле: count: Annotated[int, Field(strict=True)].
Коерсия — не баг Pydantic, а осознанный выбор по умолчанию. Но баг — это когда вы не знаете, что она включена, и ваш API принимает данные, которые не собирались принимать.
Точка 2. Валидатор вызывается не в тот момент
В Pydantic v1 валидаторы объявлялись через декоратор @validator и по умолчанию получали уже приведённое значение. В v2 появился @field_validator с параметром mode: "before" (до приведения типа) и "after" (после). По умолчанию — "after".
Проблема возникает, когда валидатор с mode="after" полагается на сырое значение. Например, вы хотите обрезать пробелы у строки:
from pydantic import BaseModel, field_validator
class UserUpdate(BaseModel):
email: str
@field_validator("email", mode="after")
@classmethod
def strip_email(cls, value: str) -> str:
return value.strip().lower()
Здесь mode="after" работает корректно: строка уже провалидирована как str. Но если тип поля — int, а вы хотите перехватить невалидную строку до приведения, mode="after" вас не спасёт — Pydantic выбросит ошибку раньше, чем вызовет валидатор. Для перехвата нужен mode="before", и тогда value приходит с типом Any.
Порядок вызовов: что за чем
| Этап | Что происходит | Когда ломается |
|---|---|---|
| 1. before-валидаторы полей | Получают сырой ввод (Any) | Если забыли проверить тип значения — TypeError в рантайме |
| 2. Приведение типа (core schema) | Pydantic-core парсит значение | Если коерсия невозможна — 422, валидатор after не вызовется |
| 3. after-валидаторы полей | Получают уже типизированное значение | Если ожидали сырой ввод — данные уже изменены |
| 4. model_validator(mode="after") | Получает собранную модель целиком | Если одно поле зависит от другого, а то не прошло — поле отсутствует |
Частая ошибка при миграции: старый @validator("field", pre=True) переносят как @field_validator("field") без указания mode="before". Код не падает, но валидатор больше не перехватывает сырые данные.
Точка 3. Optional, None и значения по умолчанию
FastAPI использует наличие или отсутствие значения по умолчанию, чтобы определить, обязательное поле или нет. Pydantic v2 строже разделяет три состояния: поле не передано, поле передано как null, поле передано с пустой строкой.
Модель name: str | None = None пропустит и отсутствие поля, и явный null. Но если вы хотите различать «клиент не прислал поле» и «клиент явно обнулил поле» — стандартной аннотации не хватит. Для PATCH-эндпоинтов это критично: обновлять только присланные поля или затирать всё?
Рецепт через model_fields_set
from pydantic import BaseModel
class ProfilePatch(BaseModel):
display_name: str | None = None
bio: str | None = None
# В хэндлере FastAPI:
async def update_profile(patch: ProfilePatch):
# patch.model_fields_set содержит только те поля, которые клиент явно прислал
update_data = patch.model_dump(
include=patch.model_fields_set, # обновляем только присланное
)
# {"bio": None} — клиент обнулил bio
# {} — клиент ничего не прислал
Если вместо этого использовать patch.model_dump(exclude_unset=True), результат будет таким же, но намерение менее явное при чтении кода.
Точка 4. Сериализация: модель в ответе не равна модели на входе
Pydantic v2 разделяет валидацию (парсинг входящих данных) и сериализацию (формирование ответа). model_dump() и model_dump_json() используют отдельный набор сериализаторов. Это значит, что кастомный тип может парситься корректно, а в JSON-ответе отдаваться не так, как вы ожидаете.
Типичный пример: поле с типом datetime. На входе Pydantic примет строку ISO 8601 и сконвертирует в объект datetime. На выходе — сериализует обратно в ISO 8601, но в формате, который может отличаться от присланного: без микросекунд, с суффиксом Z или +00:00 в зависимости от часового пояса.
Для клиента, который сравнивает отправленное и полученное побайтово, это баг. Для фронтенда, который парсит дату через new Date(), — нет. Но знать об этом нужно до прода.
Контролировать формат можно через field_serializer:
from datetime import datetime
from pydantic import BaseModel, field_serializer
class Event(BaseModel):
starts_at: datetime
@field_serializer("starts_at")
def serialize_starts_at(self, value: datetime, _info) -> str:
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
Точка 5. Генерация OpenAPI-схемы расходится с реальностью
FastAPI строит OpenAPI-схему по метаданным моделей Pydantic. Если вы используете Annotated с кастомными типами, Union-дискриминаторы или модели с наследованием, схема может не отражать фактическое поведение:
Union[Cat, Dog]без дискриминатора — в схеме появитсяanyOf, но порядок проверки определяет Pydantic, а не клиент. Первая подходящая модель побеждает.- Наследование моделей с переопределением полей — в схеме отобразится только финальный вариант, а валидатор родительского поля может остаться активным.
computed_field— появится в ответе, но не в схеме запроса. Если клиент пришлёт его, Pydantic проигнорирует значение молча (приextra="ignore", что стоит по умолчанию) или вернёт 422 (приextra="forbid").
Проверяйте сгенерированную схему вручную: /docs удобен для быстрого теста, но /openapi.json — для точного аудита. Сравнивайте с реальным поведением хотя бы для критичных эндпоинтов.
Граничные случаи: где подход ломается целиком
Pydantic хорошо работает для плоских и умеренно вложенных моделей. Вот где начинаются проблемы, которые не решаются настройкой поля:
- Глубокая рекурсия. Модель, ссылающаяся сама на себя (дерево комментариев, вложенные категории), приводит к глубокой рекурсии при валидации. Pydantic обработает это, но время парсинга растёт линейно с глубиной, а лимита по умолчанию нет. Ставьте
max_lengthна списки и проверяйте глубину отдельно. - Большие списки объектов. 50 000 элементов в одном запросе — Pydantic v2 справится за секунды, но память под промежуточные объекты может удивить. Профилируйте через
tracemalloc, если принимаете батчи. - Динамические модели.
create_model()на каждый запрос — антипаттерн: компиляция core-schema стоит дорого. Кешируйте созданные модели или пересмотрите архитектуру.
Сноска: Коерсия (coercion) — автоматическое приведение значения к целевому типу без явного указания. Строка "5" превращается в число 5. В строгом режиме (strict mode) коерсия отключена: тип должен совпадать точно.
Что делать дальше
Проведите аудит моделей проекта по пяти точкам выше. Начните с самого нагруженного эндпоинта: включите строгий режим, проверьте порядок валидаторов, убедитесь, что PATCH-модели корректно различают «не прислано» и null. Сравните /openapi.json с фактическими ответами. Если проект всё ещё на Pydantic v1 — миграционный гайд в официальной документации покрывает большинство изменений, но проверять поведение коерсии и валидаторов всё равно придётся вручную на ваших данных.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Pydantic v2 — Migration Guide (официальная документация), FastAPI — Request Body (официальная документация)
Частые вопросы
Можно ли использовать Pydantic v1-стиль валидаторов в FastAPI с Pydantic v2?
Что случится, если клиент пришлёт лишнее поле, которого нет в модели?
Строгий режим замедляет валидацию?
Как понять, какие поля клиент действительно прислал в PATCH-запросе?
Что делать, если Union-тип в ответе парсится не в ту ветку?
У меня другая версия FastAPI или Pydantic — статья всё ещё актуальна?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.