Field, ограничения и кастомные валидаторы

Field задаёт ограничения и метаданные отдельного поля, а декораторы field_validator и model_validator добавляют собственную логику проверки — по одному полю или по модели целиком.

Базовых типов мало: нужно ограничивать длину, диапазон, формат и проверять взаимосвязи полей. Для этого есть Field и валидаторы, и в v2 они называются field_validator/model_validator.

Тип str говорит «строка», но не «строка от 3 до 50 символов, без пробелов по краям». Тип int не скажет «положительное число до тысячи». Эти ограничения задаются через Field. А когда правило сложнее, чем диапазон — например, «пароль и его подтверждение должны совпадать», — нужны кастомные валидаторы.

from pydantic import BaseModel, Field, field_validator, model_validator

class Registration(BaseModel):
    username: str = Field(min_length=3, max_length=20)
    age: int = Field(ge=0, le=120)
    password: str = Field(min_length=8)
    password_repeat: str

    @field_validator("username")
    @classmethod
    def no_spaces(cls, v: str) -> str:
        if " " in v:
            raise ValueError("в username не должно быть пробелов")
        return v

    @model_validator(mode="after")
    def passwords_match(self):
        if self.password != self.password_repeat:
            raise ValueError("пароли не совпадают")
        return self

Различие важно: field_validator работает с одним полем и не видит остальные; model_validator(mode="after") запускается после проверки всех полей и видит модель целиком, поэтому именно он умеет сверять поля между собой. Любая ValueError внутри валидатора превращается FastAPI в аккуратную ошибку 422.

Как работает под капотом

Порядок таков: сперва конвертация и базовые ограничения Field, затем валидаторы полей, затем валидатор модели. Смоделируем эту цепочку на stdlib:

def field_check(name, value):
    if name == "username":
        if not (3 <= len(value) <= 20):
            raise ValueError("username: длина 3..20")
        if " " in value:
            raise ValueError("username: без пробелов")
    if name == "age":
        if not (0 <= value <= 120):
            raise ValueError("age: диапазон 0..120")
    return value

def validate_registration(data):
    errors = []
    clean = {}
    for name in ("username", "age", "password", "password_repeat"):
        try:
            clean[name] = field_check(name, data[name])   # ограничения + field_validator
        except ValueError as e:
            errors.append(str(e))
    # model_validator: сверяем поля между собой
    if not errors and clean["password"] != clean["password_repeat"]:
        errors.append("пароли не совпадают")
    return (422, errors) if errors else (200, "ok")

print(validate_registration({"username": "an na", "age": 30, "password": "12345678", "password_repeat": "12345678"}))
print(validate_registration({"username": "anna", "age": 200, "password": "abcdefgh", "password_repeat": "xxxxxxxx"}))
print(validate_registration({"username": "anna", "age": 30, "password": "abcdefgh", "password_repeat": "abcdefgh"}))

Попробуй сам ▶ Обрати внимание: проверка совпадения паролей возможна только после проверки отдельных полей — как и model_validator(mode="after").

Частые ошибки

Первая — пытаться сверять два поля внутри field_validator: он видит только своё поле. Для межполевых проверок нужен model_validator. Вторая — забывать @classmethod у field_validator и неправильную сигнатуру (в v2 это (cls, v)). Третья — использовать устаревшие имена v1 (@validator, @root_validator) в проекте на v2. Четвёртая — возвращать из валидатора None вместо проверенного значения, обнуляя поле.

Best practices

  • Простые ограничения (длина, диапазон, паттерн) задавайте через Field, а не валидаторами.
  • Один-полевые проверки — field_validator; межполевые — model_validator(mode="after").
  • Бросайте ValueError с понятным текстом — FastAPI превратит его в 422.
  • Всегда возвращайте проверенное значение (или self в model_validator).

Режимы валидаторов: before и after

У валидаторов есть важный параметр режима. model_validator(mode="before") срабатывает до конвертации и базовой валидации — он получает сырые входные данные и удобен, когда нужно их предобработать или собрать недостающие поля. mode="after" срабатывает после, получает уже провалидированную модель и подходит для проверки взаимосвязей. То же различие есть у field_validator. Понимание момента запуска решает реальные задачи: например, если вы хотите принять дату и в виде строки, и в виде числа, нормализацию делают в before-валидаторе, до того как Pydantic применит строгую проверку типа. А вот сверку «дата окончания не раньше даты начала» — в after, когда оба поля уже корректны. Выбор режима — это выбор «до или после того, как Pydantic привёл данные в порядок», и он определяет, с чем именно работает ваш код.

Итог: Field ограничивает поле декларативно, field_validator добавляет логику одного поля, model_validator — проверки по всей модели. Порядок: ограничения → поля → модель.

Проверьте себя
1. Почему совпадение двух полей (password и password_repeat) нельзя проверить в field_validator?
AField_validator вообще не умеет бросать ошибки
Bfield_validator видит только одно своё поле и не имеет доступа к остальным; для межполевых проверок нужен model_validator
CЭто запрещено синтаксисом Python
DПароли нельзя сравнивать
2. Во что FastAPI превращает ValueError, выброшенную внутри валидатора Pydantic?
AВ 500 Internal Server Error
BВ падение сервера
CВ структурированную ошибку валидации 422 с понятным сообщением
DИгнорирует её