Кастомная валидация: field_validator и model_validator
Когда стандартных типов и Field(...) мало, валидацию пишут руками: field_validator проверяет одно поле, model_validator — всю модель целиком.
Валидатор — это функция внутри модели Pydantic, которую вызывают во время разбора входных данных, чтобы проверить или поправить значение. Если функция бросает
ValueError, Pydantic превращает это в понятную ошибку валидации, а FastAPI — в ответ422.
Типы и ограничения вроде Field(gt=0, max_length=50) покрывают большинство случаев: «число больше нуля», «строка не длиннее 50». Но реальные правила часто сложнее: «пароль и его подтверждение должны совпадать», «email привести к нижнему регистру», «если выбран тариф premium, поле company обязательно». Такие проверки в один атрибут не упаковать — для них и существуют кастомные валидаторы.
Этот урок — про два декоратора Pydantic v2 (field_validator и model_validator), про критически важный параметр mode (когда именно срабатывает валидатор — до или после приведения типа) и про то, как выносить общие проверки в переиспользуемые куски через Annotated и AfterValidator.
Зачем это на практике
Валидаторы решают повседневные задачи бэкенда:
- Нормализация ввода: обрезать пробелы, привести email к нижнему регистру, убрать лишние символы из телефона — до того, как данные уйдут в БД.
- Кросс-полевые правила: дата окончания позже даты начала; пароль совпадает с подтверждением; ровно одно из двух взаимоисключающих полей заполнено.
- Бизнес-проверки: код купона в верхнем регистре и из разрешённого набора; сумма заказа не превышает лимит для роли пользователя.
- Единая защита на входе API: FastAPI прогоняет тело запроса через модель, поэтому любой кривой ввод отсекается ещё до вашей бизнес-логики, и вы получаете аккуратный
422с описанием поля.
Код в уроке использует from pydantic import ... — это серверная библиотека, в браузере её нет, поэтому кнопки «Запустить» под такими блоками не будет: читайте их как образец. Идею порядка проверок мы отдельно покажем на чистом Python, который реально исполняется.
field_validator: проверка одного поля
Декоратор field_validator вешается на метод-классметод и получает значение конкретного поля. Метод обязан вернуть значение (возможно, изменённое) — именно оно попадёт в модель. Чтобы отклонить ввод, бросают ValueError.
from pydantic import BaseModel, field_validator
class User(BaseModel):
name: str
email: str
@field_validator("email")
@classmethod
def normalize_email(cls, v: str) -> str:
v = v.strip().lower()
if "@" not in v:
raise ValueError("email должен содержать @")
return v
u = User(name="Анна", email=" [email protected] ")
print(u.email) # [email protected]
Здесь валидатор делает две вещи: нормализует (обрезка + нижний регистр) и проверяет (наличие @). Возвращённое значение заменяет исходное, поэтому в модели окажется уже чистый email. Один валидатор можно повесить на несколько полей сразу: @field_validator("email", "backup_email") — или на все поля символом "*".
mode="before" против mode="after"
Ключевой нюанс — когда запускается валидатор относительно приведения типа. У field_validator есть параметр mode:
| mode | Когда срабатывает | Что на входе |
"after" (по умолчанию) | после приведения к типу поля | уже значение нужного типа (например, готовый int) |
"before" | до приведения к типу | сырой ввод, как пришёл (часто str или dict) |
Правило простое: before — чтобы подготовить сырые данные к типу (распарсить строку, превратить "a,b,c" в список), after — чтобы проверить уже типизированное значение. Пример before-валидатора, который принимает и список, и строку через запятую:
from pydantic import BaseModel, field_validator
class Post(BaseModel):
tags: list[str]
@field_validator("tags", mode="before")
@classmethod
def split_tags(cls, v):
if isinstance(v, str):
return [t.strip() for t in v.split(",") if t.strip()]
return v
print(Post(tags="python, fastapi , web").tags)
# ['python', 'fastapi', 'web']
Если бы валидатор был after, Pydantic сначала попытался бы привести строку "python, fastapi, web" к list[str], упал бы с ошибкой типа — и до нашей логики дело бы не дошло. Поэтому «починку формата» делают именно в before.
Наглядно: порядок шагов валидации
Чтобы прочувствовать последовательность «сырое → приведение → проверка», вот тот же конвейер на чистом Python (он исполняется):
def before(v): # 1) готовим сырое значение
return v.strip()
def coerce(v): # 2) приведение типа
return int(v)
def after(v): # 3) проверка готового значения
if v < 0:
raise ValueError("отрицательное")
return v
raw = " 42 "
v = after(coerce(before(raw)))
print("результат:", v)
print("тип:", type(v).__name__)
Вывод:
результат: 42 тип: int
Pydantic делает ровно это: сперва ваши before-валидаторы, затем приведение к объявленному типу, затем ваши after-валидаторы. Понимание порядка снимает большую часть вопросов «почему мой валидатор получает не тот тип, что я ждал».
model_validator: правила между полями
Когда проверка касается нескольких полей сразу, поле-валидатор не подходит — он видит только своё значение. Тут нужен model_validator. В режиме after он получает уже собранный объект модели и может сравнивать поля между собой:
from pydantic import BaseModel, model_validator
class SignUp(BaseModel):
password: str
password_confirm: str
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.password_confirm:
raise ValueError("пароли не совпадают")
return self
SignUp(password="secret123", password_confirm="secret123") # ок
SignUp(password="a", password_confirm="b") # ValidationError
Обратите внимание: model_validator(mode="after") — это обычный метод (принимает self), а не классметод, и он обязан вернуть self. Режим before у модели работает иначе: он получает сырой dict входных данных до создания полей — это удобно, чтобы переименовать ключи или собрать одно поле из нескольких, но обращаться к self.field там ещё нельзя.
Переиспользование: Annotated и AfterValidator
Если одна и та же проверка нужна в десяти моделях, копировать метод в каждую — плохо. Pydantic v2 даёт функциональные валидаторы, которые навешиваются прямо на тип через Annotated. Пишете обычную функцию и оборачиваете тип в AfterValidator (есть и BeforeValidator):
from typing import Annotated
from pydantic import BaseModel, AfterValidator
def must_be_positive(v: int) -> int:
if v <= 0:
raise ValueError("должно быть больше нуля")
return v
PositiveInt = Annotated[int, AfterValidator(must_be_positive)]
class Order(BaseModel):
quantity: PositiveInt
bonus: PositiveInt
Order(quantity=3, bonus=10) # ок
Order(quantity=0, bonus=10) # ValidationError для quantity
Теперь PositiveInt — переиспользуемый тип: объявили один раз, применяете где угодно. Это чище классических методов-валидаторов, потому что проверка живёт рядом с типом, а не дублируется в каждой модели. AfterValidator срабатывает после приведения (на входе уже int), BeforeValidator — до него (на входе сырое значение).
Как это работает под капотом
Pydantic v2 на старте собирает для каждой модели так называемый core schema — описание, как валидировать модель, — и компилирует его в быстрый валидатор, написанный на Rust (библиотека pydantic-core). Ваши валидаторы встраиваются в этот конвейер как «врезки» в нужных точках: before-валидаторы — перед узлом приведения типа, after-валидаторы — после него. Поэтому накладные расходы малы: основную работу делает скомпилированное ядро, а Python-функция вызывается ровно там, где вы её повесили.
Из этого следуют практические факты. Во-первых, валидаторы запускаются при каждом создании или разборе модели, так что тяжёлую работу (сетевые запросы, обращения к БД) внутрь них класть не стоит — это замедлит весь разбор. Во-вторых, порядок строго детерминирован: сначала все before, затем приведение типа, затем все after, и только потом model_validator(mode="after") на уровне всей модели. Зная это, легко предсказать, какой тип получит ваша функция.
Частые ошибки
- Валидатор ничего не возвращает. Метод обязан вернуть значение. Если забыть
return, поле станетNone— частый источник «исчезнувших» данных. - Бросают не ValueError. Чтобы Pydantic превратил сбой в ошибку валидации (и FastAPI отдал
422), бросайтеValueError(илиAssertionError). Свои исключения он перехватывать не обязан. - Путают before и after. Чинить формат сырых данных (строка→список) нужно в
before; проверять готовое типизированное значение — вafter. Перепутав, получаете ошибку типа ещё до своей логики. - Забыли @classmethod у field_validator. Поле-валидатор — это классметод (первый аргумент
cls). А вотmodel_validator(mode="after")— наоборот, обычный метод сself; их легко перепутать. - Кросс-полевую проверку пишут в field_validator. Поле-валидатор видит только своё значение и не имеет доступа к другим полям; для сравнения полей нужен
model_validator.
Итоги
field_validatorпроверяет/нормализует одно поле (классметод, возвращает значение);model_validator— правила между полями (в режиме after — метод сself, возвращаетself).mode="before"получает сырой ввод до приведения типа (чинить формат),mode="after"— уже типизированное значение (проверять).- Отклонение ввода — это
raise ValueError(...); Pydantic превратит его в ошибку валидации, FastAPI — в ответ422. - Общие проверки выносите в переиспользуемый тип через
Annotated[T, AfterValidator(fn)]вместо копирования методов. - Порядок фиксирован: все
before→ приведение типа → всеafter→model_validator(after); тяжёлую работу в валидаторы не кладите.