Pydantic для валидации данных: схемы вместо ручных проверок
Как заменить россыпь if-проверок на декларативную схему: валидаторы, разница между Pydantic v1 и v2, где схема ломается на проде и что с этим делать.

Вы получаете JSON из внешнего API: словарь с датами-строками, числами в кавычках, полем, которого иногда нет. Ручная проверка превращается в лестницу из if 'email' not in data, try: int(data['age']) и десятка веток на невалидные значения. Через полгода никто не помнит, какие поля обязательны. Pydantic переносит эти проверки в объявление модели: вы описываете форму данных типами Python, а библиотека сама парсит, приводит типы и собирает понятные ошибки. Ниже — как это работает на Pydantic 2.x, чем валидаторы отличаются от ручных проверок и где схема всё-таки не спасает.
Что вы выигрываете, заменив ручные проверки
Разберём на конкретном примере. Пусть на вход приходит регистрация пользователя. Ручной вариант выглядит так:
def parse_user(data: dict):
if "email" not in data:
raise ValueError("нет email")
email = data["email"]
if "@" not in email:
raise ValueError("кривой email")
age = data.get("age")
if age is not None:
age = int(age) # упадёт TypeError на списке
if age < 18:
raise ValueError("меньше 18")
return {"email": email, "age": age}
Проблемы видны сразу: ошибки разного типа (ValueError, TypeError), проверки размазаны, а на десяти полях функция станет нечитаемой. Схема на Pydantic делает то же самое декларативно:
# Pydantic 2.x, установка: pip install "pydantic>=2,<3"
# для проверки email отдельно: pip install "pydantic[email]"
from pydantic import BaseModel, EmailStr, Field
class User(BaseModel):
email: EmailStr
age: int | None = Field(default=None, ge=18)
# невалидные данные соберут все ошибки разом
user = User(email="a@b.com", age="25") # "25" приведётся к int 25
print(user.age) # 25
Строка "25" сама стала числом — Pydantic по умолчанию приводит совместимые типы. Поле age необязательно, но если оно есть, ограничение ge=18 проверяется автоматически. При ошибке вы получаете один ValidationError со списком всех проблемных полей, а не первую попавшуюся.
Ручная проверка отвечает на вопрос «что не так с этим конкретным полем». Схема отвечает на вопрос «соответствуют ли данные контракту целиком» — и это разные задачи. Как только полей больше трёх, второй вопрос выигрывает.
Ограничения полей: Field вместо самописных условий
Большая часть ручных проверок — это диапазоны, длины и форматы. Всё это описывается параметрами Field без единой строки логики.
gt,ge,lt,le— численные границы (больше, больше-или-равно и так далее);min_length,max_length— для строк и коллекций;pattern— регулярное выражение для строк;defaultиdefault_factory— значение по умолчанию; фабрику используйте для изменяемых объектов вроде списков, иначе один список разделят все экземпляры.
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1, max_length=120)
price: float = Field(gt=0)
tags: list[str] = Field(default_factory=list)
sku: str = Field(pattern=r"^[A-Z]{2}-\d{4}$")
Product(name="Кружка", price=490, sku="AB-1234")
Если price придёт нулём, а sku не совпадёт с шаблоном, оба нарушения попадут в один ValidationError. Это удобно для API: клиенту возвращается полный список того, что нужно исправить, за один запрос.
Кастомные валидаторы: когда встроенных ограничений мало
Бизнес-правила вроде «дата окончания позже даты начала» или «нормализуй телефон к единому формату» встроенными параметрами не описать. Для этого в Pydantic 2 есть декораторы field_validator и model_validator.
Валидация и нормализация одного поля
from pydantic import BaseModel, field_validator
class Contact(BaseModel):
phone: str
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str) -> str:
digits = "".join(c for c in value if c.isdigit())
if len(digits) != 11:
raise ValueError("нужно 11 цифр")
# приводим 8XXX к 7XXX
if digits.startswith("8"):
digits = "7" + digits[1:]
return "+" + digits
print(Contact(phone="8 (912) 345-67-89").phone) # +79123456789
Валидатор не только проверяет, но и возвращает нормализованное значение — оно и попадёт в модель. Это то, что в ручном коде обычно делают отдельной функцией и забывают вызвать.
Проверки на уровне модели
Когда правило связывает несколько полей, нужен model_validator с режимом after — он получает уже собранный объект:
from datetime import date
from pydantic import BaseModel, model_validator
class Booking(BaseModel):
start: date
end: date
@model_validator(mode="after")
def check_dates(self) -> "Booking":
if self.end <= self.start:
raise ValueError("end должен быть позже start")
return self
Режим before получает сырые данные до валидации типов — его берут, когда нужно переименовать поля или собрать значение из нескольких ключей входного словаря.
Разница между v1 и v2: что сломается при переходе
Pydantic 2 вышел с переписанным на Rust ядром и заметно быстрее, но синтаксис валидаторов и часть методов изменились. Если вы читаете старый код или гайд, сверьтесь с таблицей — половина ошибок при миграции именно здесь.
| Задача | Pydantic 1.x | Pydantic 2.x |
|---|---|---|
| Валидатор поля | @validator("field") | @field_validator("field") |
| Валидатор модели | @root_validator | @model_validator |
| Модель → словарь | .dict() | .model_dump() |
| Модель → JSON | .json() | .model_dump_json() |
| Разбор из dict | .parse_obj() | .model_validate() |
| Настройки модели | класс Config | model_config = ConfigDict(...) |
Методы .dict() и .json() в версии 2 помечены как устаревшие и вызывают предупреждение — они ещё работают, но опираться на них в новом коде не стоит. Точный список изменений и что удалено окончательно — в официальном руководстве по миграции.
Где схема не спасает
Pydantic решает форму данных, а не их смысл. Несколько мест, где ожидания расходятся с реальностью:
- Тихое приведение типов. По умолчанию
"25"станет числом, а1— булевымTrue. Если вам нужна строгость, включайте её точечно через типStrictInt,StrictStrили глобально черезmodel_config = ConfigDict(strict=True). Иначе валидатор пропустит то, что вы считали ошибкой. - Лишние поля молча отбрасываются. По умолчанию ключи, которых нет в модели, игнорируются. Чтобы поймать опечатку в имени поля, поставьте
model_config = ConfigDict(extra="forbid"). - Валидация не бесплатна. На больших массивах объектов разбор через модель заметно медленнее прямого доступа к словарю. Для горячего пути с миллионами записей это стоит замерить, а не считать пренебрежимым.
- Порядок валидаторов не всегда очевиден.
field_validatorс режимомbeforeотработает до приведения типа,after— после. Перепутаете — получите строку там, где ждали число, или наоборот.
Мутационная валидация — то есть проверка при каждом присваивании атрибута, а не только при создании — по умолчанию выключена и включается через validate_assignment=True. Это отдельная тема, здесь мы разбираем валидацию на входе.
Как это выглядит целиком
Собранная модель на десяток полей заменяет функцию на сорок строк ручных проверок и попутно документирует контракт: любой, кто откроет класс, видит обязательные поля, типы и ограничения.
from datetime import date
from pydantic import BaseModel, EmailStr, Field, field_validator, ConfigDict
class Order(BaseModel):
model_config = ConfigDict(extra="forbid") # ловим опечатки в ключах
email: EmailStr
quantity: int = Field(gt=0, le=1000)
delivery_date: date
comment: str = Field(default="", max_length=500)
@field_validator("delivery_date")
@classmethod
def not_in_past(cls, value: date) -> date:
if value < date.today():
raise ValueError("дата доставки в прошлом")
return value
raw = {"email": "user@shop.ru", "quantity": "3", "delivery_date": "2099-01-01"}
order = Order.model_validate(raw)
print(order.model_dump())
Вход — сырой словарь с числом-строкой и датой-строкой. На выходе — типизированный объект с приведёнными типами и проверенными правилами.
Что делать дальше
Если пишете API — Pydantic уже встроен в FastAPI, там модели работают как схемы запроса и ответа заодно. Отдельная тема, которую стоит освоить следующей, — Settings из пакета pydantic-settings: тот же принцип схемы применяется к переменным окружения и конфигам, что убирает ручной разбор os.environ. Для интеграции с существующими классами посмотрите @dataclass-совместимый режим и TypeAdapter — он валидирует произвольные типы без наследования от BaseModel. Точный набор возможностей зависит от минорной версии — сверяйтесь с документацией той, что стоит у вас в pip freeze.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Pydantic — официальная документация, Pydantic v2 Migration Guide
Частые вопросы
У меня в проекте Pydantic 1.x, примеры не работают
Почему строка "25" превратилась в число, я этого не просил
Что будет, если во входных данных есть лишнее поле, которого нет в модели
Валидатор не срабатывает, ошибка не поднимается
Насколько это медленнее ручных проверок и стоит ли бояться на проде
Можно ли использовать Pydantic без FastAPI, в обычном скрипте
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.