Кастомная валидация: 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 → приведение типа → все aftermodel_validator(after); тяжёлую работу в валидаторы не кладите.
Проверьте себя
1. Чем отличается field_validator с mode="before" от mode="after"?
Abefore получает сырой ввод до приведения типа (удобно чинить формат), after — уже значение нужного типа (удобно проверять)
Bbefore запускается только на проде, after — только в тестах
Cbefore работает с несколькими полями, after — строго с одним
DРазницы нет, mode влияет лишь на текст сообщения об ошибке
2. Какую проверку нельзя сделать в field_validator и нужно делать в model_validator?
AСравнить два поля между собой, например password и password_confirm
BОбрезать пробелы у строки
CПроверить, что число больше нуля
DПривести email к нижнему регистру
3. Как переиспользовать одну проверку (например «положительное число») сразу в нескольких моделях без дублирования метода?
AОбъявить тип Annotated[int, AfterValidator(must_be_positive)] и использовать его как обычный тип поля
BСкопировать метод-валидатор в каждую модель вручную
CСделать поле глобальной переменной модуля
DЭто невозможно — валидатор всегда привязан к конкретной модели