Написание собственных валидаторов, режимы before/after/wrap, валидация нескольких полей сразу.
Когда встроенных ограничений Field недостаточно — пишем свои валидаторы
В предыдущей теме вы изучили Field — он отлично подходит для простых ограничений:
from pydantic import BaseModel, Field
class Product(BaseModel):
price: float = Field(gt=0) # Цена > 0
name: str = Field(min_length=2) # Имя >= 2 символа
age: int = Field(ge=0, le=150) # Возраст 0-150Но что если логика сложнее?
| Сценарий | Field справится? | Почему |
|---|---|---|
age > 0 | ✅ Да | Field(gt=0) |
password минимум 8 символов | ✅ Да | Field(min_length=8) |
email содержит @ | ❌ Нет | Нужна проверка формата |
password содержит заглавную и цифру | ❌ Нет | Нужна сложная логика |
end_date > start_date | ❌ Нет | Зависит от двух полей |
username не зарезервирован | ❌ Нет | Нужна проверка по списку/БД |
password == password_confirm | ❌ Нет | Нужно сравнение двух полей |
Кастомные валидаторы — это Python-функции, которые запускаются при создании модели. Внутри можно написать любую логику:
Пользователь создаёт модель
↓
Pydantic: стандартная валидация (типы, Field)
↓
@field_validator: ваша логика для конкретных полей
↓
@model_validator: ваша логика для всей модели
↓
Модель создана ✓ или ValidationError ✗
| Инструмент | Для чего | Пример |
|---|---|---|
Field(...) | Простые ограничения | gt=0, min_length=8 |
@field_validator | Сложная проверка одного поля | формат email, сложность пароля |
@model_validator | Проверка взаимосвязи полей | end_date > start_date, пароли совпадают |
Правило: Всегда начинайте с Field. Если не хватает — добавляйте валидаторы.
@field_validator — это декоратор, который говорит Pydantic: "Перед тем как создать модель, пропусти значение этого поля через мою функцию".
Что мы сейчас сделаем: Создадим модель User с валидацией email.
from pydantic import BaseModel, field_validator
class User(BaseModel):
username: str
email: str
@field_validator('email')
@classmethod
def validate_email(cls, v: str) -> str:
"""
Проверяет что email содержит @ и домен.
Параметры:
v — значение поля (email)
Возвращает:
v — если валидно
Выбрасывает:
ValueError — если не валидно
"""
if '@' not in v:
raise ValueError('Email должен содержать @')
if not v.endswith(('.com', '.org', '.net')):
raise ValueError('Недопустимый домен email')
return v
# ✅ OK — проходит все проверки
user = User(username="alice", email="alice@example.com")
print(user.email) # alice@example.com
# ❌ Ошибка — нет @
User(username="alice", email="invalid-email")
# ValidationError: 1 validation error for User
# email
# Value error, Email должен содержать @ [type=value_error, ...]
# ❌ Ошибка — плохой домен
User(username="alice", email="alice@example.xyz")
# ValidationError: 1 validation error for User
# email
# Value error, Недопустимый домен email [type=value_error, ...]@field_validator('email') # 1. Указываем КАКОЕ поле валидировать
@classmethod # 2. Обязательно! Это классовый метод
def validate_email(cls, v): # 3. cls — класс, v — значение поля
if not_valid:
raise ValueError(...) # 4. Ошибка = валидация провалена
return v # 5. Возврат = валидация пройденаКлючевые правила:
| Правило | Почему | Что будет если нарушить |
|---|---|---|
@classmethod обязателен | Pydantic вызывает метод на классе, не на экземпляре | TypeError: ... takes 1 positional argument but 2 were given |
Первый аргумент cls | Требование classmethod | Не будет работать |
Второй аргумент v | Значение поля для проверки | Не будет доступа к значению |
Обязательно вернуть v | Pydantic использует возвращаемое значение | Поле станет None |
Ошибка = ValueError | Pydantic ловит только ValueError | Ошибка не будет перехвачена |
Запомните:
@field_validatorполучает значение одного поля. Он не знает о других полях модели (по умолчанию).
Зачем это нужно: Режим определяет когда ваш валидатор запускается относительно стандартной валидации Pydantic.
| Режим | Когда запускается | Что получает | Для чего |
|---|---|---|---|
mode='after' | После стандартной валидации | Уже преобразованное значение | Проверка значений (возраст 0-150) |
mode='before' | До стандартной валидации | Сырое входное значение | Кастомный парсинг/трансформация |
mode='wrap' | Вместо стандартной валидации | Сырое значение + handler | Полный контроль |
Когда использовать: Когда вам нужно проверить значение, которое уже преобразовано к правильному типу.
class User(BaseModel):
age: int
@field_validator('age', mode='after') # mode='after' можно опустить
@classmethod
def validate_age(cls, v: int) -> int:
"""
v уже преобразован в int.
Если пользователь передал "25" (строку),
Pydantic уже преобразовал её в 25.
"""
if v < 0 or v > 150:
raise ValueError('Возраст должен быть 0-150')
return v
# Строка "25" сначала преобразуется в 25, потом проходит валидатор
user = User(age="25") # OK, age = 25 (int)
print(type(user.age)) # <class 'int'>
# ❌ Валидатор отработал ПОСЛЕ преобразования
User(age="-5")
# ValidationError: Возраст должен быть 0-150
# ❌ Pydantic не смог преобразовать (не число)
User(age="не число")
# ValidationError: Input should be a valid integerПоток выполнения:
Вход: age="25" (строка)
↓
Pydantic: преобразует "25" → 25 (int)
↓
@field_validator(mode='after'): получает v=25
↓
Проверка: 0 <= 25 <= 150? ✅
↓
Результат: age=25
Когда использовать: Когда нужно кастомно распарсить входные данные до того как Pydantic попробует их преобразовать.
from datetime import datetime
class Event(BaseModel):
date: datetime
@field_validator('date', mode='before')
@classmethod
def parse_date(cls, v) -> datetime:
"""
v — сырое входное значение (может быть строкой, int, datetime).
Мы можем перехватить и обработать ДО Pydantic.
"""
if isinstance(v, str):
# Кастомный парсинг — только наш формат
return datetime.strptime(v, '%Y-%m-%d')
# Если не строка — пусть Pydantic разбирается сам
return v
# ✅ Строка → наш парсер → datetime
event = Event(date="2026-03-18")
print(event.date) # 2026-03-18 00:00:00
# ❌ Неправильный формат — наш парсер упадёт
Event(date="18/03/2026")
# ValueError: time data '18/03/2026' does not match format '%Y-%m-%d'
# ✅ Datetime — пропустили как есть
from datetime import datetime
event2 = Event(date=datetime(2026, 3, 18))Поток выполнения:
Вход: date="2026-03-18" (строка)
↓
@field_validator(mode='before'): получает v="2026-03-18"
↓
Наш парсер: strptime → datetime(2026, 3, 18)
↓
Pydantic: получает datetime — уже правильный тип
↓
Результат: date=datetime(2026, 3, 18)
Зачем mode='before'? Pydantic по умолчанию принимает множество форматов дат. Если вам нужен строго один формат — перехватите до Pydantic.
Когда использовать: Когда нужен полный контроль — проверить до И после стандартной валидации.
class User(BaseModel):
password: str
@field_validator('password', mode='wrap')
@classmethod
def validate_password(cls, v, handler):
"""
v — сырое значение
handler — функция стандартной валидации Pydantic
Мы решаем КОГДА вызвать handler: до, после, или вообще не вызывать.
"""
# === ДО стандартной валидации ===
# Проверяем длину на сыром значении
if len(v) < 8:
raise ValueError('Пароль минимум 8 символов')
# === Вызываем стандартную валидацию ===
value = handler(v) # Pydantic преобразует/проверит тип
# === ПОСЛЕ стандартной валидации ===
# Проверяем сложность на уже валидированном значении
if not any(c.isupper() for c in value):
raise ValueError('Пароль должен содержать заглавную букву')
if not any(c.isdigit() for c in value):
raise ValueError('Пароль должен содержать цифру')
return value
# ✅ Проходит все проверки
user = User(password="SecurePass123")
# ❌ Слишком короткий (наша проверка ДО)
User(password="Short1")
# ValidationError: Пароль минимум 8 символов
# ❌ Нет заглавной (наша проверка ПОСЛЕ)
User(password="lowercase123")
# ValidationError: Пароль должен содержать заглавную буквуПоток выполнения:
Вход: password="SecurePass123"
↓
mode='wrap': получаем v="SecurePass123" + handler
↓
Наша проверка ДО: len >= 8? ✅
↓
handler(v): Pydantic проверяет тип → str ✅
↓
Наша проверка ПОСЛЕ: заглавная? ✅ цифра? ✅
↓
Результат: password="SecurePass123"
Аналогия:
mode='wrap'— как обёртка подарка. Вы решаете что сделать с подарком до того как завернуть, как завернуть, и после проверить.
@field_validator может применяться к нескольким полям одновременно:
class User(BaseModel):
email: str
username: str
phone: str
@field_validator('email', 'username', 'phone')
@classmethod
def validate_not_empty(cls, v: str) -> str:
"""
Этот валидатор запустится для КАЖДОГО из трёх полей.
Один код — три поля.
"""
if not v.strip():
raise ValueError('Поле не может быть пустым')
return v
# ❌ Ошибка на поле username
User(email="test@test.com", username=" ", phone="+79991234567")
# ValidationError: 1 validation error for User
# username
# Value error, Поле не может быть пустым [type=value_error, ...]Как это работает:
@field_validator('email', 'username', 'phone')
↓
email="test@test.com" → validate_email("test@test.com") ✅
username=" " → validate_username(" ") ❌
phone=... → не запускается (уже ошибка)
Когда удобно: Когда одинаковая логика применяется к нескольким полям (проверка на пустоту, trim пробелов и т.д.)
@field_validator проверяет одно поле. Но что если нужно сравнить два поля?
# ❌ ЭТО НЕ РАБОТАЕТ с @field_validator
class Event(BaseModel):
start_date: datetime
end_date: datetime
# Как проверить что end_date > start_date?
# @field_validator не знает о другом поле!Решение: @model_validator — получает всю модель целиком.
from pydantic import BaseModel, model_validator
from datetime import datetime
class Event(BaseModel):
start_date: datetime
end_date: datetime
@model_validator(mode='after')
def check_dates(self) -> 'Event':
"""
self — это уже созданная модель.
Доступны все поля: self.start_date, self.end_date
"""
if self.end_date < self.start_date:
raise ValueError('end_date должен быть после start_date')
return self
# ✅ end_date > start_date
event = Event(
start_date=datetime(2026, 1, 1),
end_date=datetime(2026, 12, 31)
)
# ❌ end_date < start_date
Event(
start_date=datetime(2026, 12, 31),
end_date=datetime(2026, 1, 1)
)
# ValidationError: 1 validation error for Event
# Value error, end_date должен быть после start_date [type=value_error, ...]Поток выполнения:
Вход: {start_date: 2026-01-01, end_date: 2026-12-31}
↓
Pydantic: создаёт модель Event
↓
@model_validator(mode='after'): получает self (готовую модель)
↓
Проверка: self.end_date > self.start_date? ✅
↓
return self → модель создана
Важно:
@model_validator(mode='after')НЕ нужен@classmethod, потому что получает экземпляр (self), а не класс (cls).
Когда использовать: Когда нужно проверить или изменить входные данные до того как Pydantic создаст модель.
class User(BaseModel):
password: str
password_confirm: str
@model_validator(mode='before')
@classmethod
def check_passwords_match(cls, values):
"""
values — dict с входными данными.
Модель ещё не создана.
"""
# values = {'password': 'abc', 'password_confirm': 'xyz'}
pwd = values.get('password')
pwd_confirm = values.get('password_confirm')
if pwd != pwd_confirm:
raise ValueError('Пароли не совпадают')
# Обязательно возвращаем values (изменённые или нет)
return values
# ✅ Пароли совпадают
user = User(password="SecurePass1", password_confirm="SecurePass1")
# ❌ Пароли не совпадают
User(password="SecurePass1", password_confirm="SecurePass2")
# ValidationError: Пароли не совпадаютПоток выполнения:
Вход: {'password': 'abc', 'password_confirm': 'xyz'}
↓
@model_validator(mode='before'): получает values=dict
↓
Проверка: password == password_confirm? ❌
↓
raise ValueError → модель НЕ создаётся
Иногда нужно проверить одно поле, но использовать значение другого. Для этого есть ValidationInfo:
from pydantic import BaseModel, field_validator, ValidationInfo
class Order(BaseModel):
price: float
quantity: int
discount: float = 0
total: float = 0
@field_validator('total', mode='after')
@classmethod
def calculate_total(cls, v, info: ValidationInfo) -> float:
"""
info.data — dict с уже валидированными полями модели.
"""
data = info.data
price = data.get('price', 0)
quantity = data.get('quantity', 1)
discount = data.get('discount', 0)
return price * quantity * (1 - discount)
order = Order(price=100, quantity=2, discount=0.1)
print(order.total) # 180.0 (100 * 2 * 0.9)Что содержит ValidationInfo:
| Атрибут | Что содержит | Пример |
|---|---|---|
info.data | Уже валидированные поля модели | {'price': 100, 'quantity': 2} |
info.field_name | Имя текущего поля | 'total' |
info.context | Внешний контекст (передаётся при вызове) | {'admin_check': True} |
Когда использовать: Когда нужно значение другого поля, но не нужна полноценная модель. Если нужна вся модель — используйте
@model_validator.
Контекст — это внешние данные, которые передаются при валидации. Полезно когда логика зависит от роли пользователя, окружения и т.д.
class User(BaseModel):
username: str
role: str
@field_validator('username', mode='after')
@classmethod
def validate_username(cls, v, info: ValidationInfo) -> str:
# Получаем внешний контекст
context = info.context
if context and context.get('admin_check'):
if v == 'admin':
raise ValueError('Имя "admin" зарезервировано')
return v
# Без контекста — проходит
user1 = User.model_validate(
{'username': 'admin', 'role': 'user'}
)
print(user1.username) # admin ✅
# С контекстом admin_check — ошибка
User.model_validate(
{'username': 'admin', 'role': 'user'},
context={'admin_check': True}
)
# ValidationError: Имя "admin" зарезервированоЗачем это нужно:
| Сценарий | Контекст | Логика |
|---|---|---|
| Админ создаёт пользователя | {'admin_check': True} | Заблокировать имя "admin" |
| Обычная регистрация | {} или нет контекста | Разрешить любое имя |
| Тестовое окружение | {'env': 'test'} | Пропустить слабые пароли |
| Production | {'env': 'prod'} | Строгие проверки |
Лучшая практика: Используйте Field для простых ограничений, валидаторы — для сложной логики.
from pydantic import BaseModel, Field, field_validator
class Product(BaseModel):
price: float = Field(gt=0, description="Цена товара")
discount: float = Field(ge=0, le=1, description="Скидка от 0 до 1")
@field_validator('discount')
@classmethod
def check_discount_reasonable(cls, v: float) -> float:
"""
Field уже проверил 0 <= discount <= 1.
Мы добавляем дополнительную проверку.
"""
if v > 0.9:
raise ValueError('Скидка не может быть больше 90%')
return v
# ✅ Field: 0 <= 0.5 <= 1, Валидатор: 0.5 <= 0.9
product = Product(price=100, discount=0.5)
# ❌ Валидатор: 0.95 > 0.9
Product(price=100, discount=0.95)
# ValidationError: Скидка не может быть больше 90%
# ❌ Field: -1 < 0
Product(price=-1, discount=0.5)
# ValidationError: Input should be greater than 0Порядок выполнения:
Вход: {price: 100, discount: 0.5}
↓
Field: price > 0? ✅ discount 0-1? ✅
↓
@field_validator('discount'): 0.5 <= 0.9? ✅
↓
Результат: Product(price=100, discount=0.5)
Почему не написать всё в Field? Field не может сравнить значение с константой (0.9). Для этого нужен валидатор.
Симптом:
TypeError: validate_email() takes 2 positional arguments but 3 were given
Проблема:
class User(BaseModel):
email: str
@field_validator('email')
def validate_email(cls, v): # ❌ Нет @classmethod
return vРешение:
@field_validator('email')
@classmethod # ← Обязательно!
def validate_email(cls, v):
return vПочему? Pydantic вызывает валидатор на классе модели, не на экземпляре. Без
@classmethodPython пытается передатьselfкак первый аргумент.
Симптом: Поле становится None или ошибка типа.
Проблема:
@field_validator('email')
@classmethod
def validate_email(cls, v):
if '@' not in v:
raise ValueError('Invalid')
# ❌ Нет return v!Решение:
@field_validator('email')
@classmethod
def validate_email(cls, v):
if '@' not in v:
raise ValueError('Invalid')
return v # ← Обязательно!Хорошая новость: Для mode='after' @classmethod НЕ нужен:
class Event(BaseModel):
start_date: datetime
end_date: datetime
@model_validator(mode='after')
def check_dates(self): # ← Нет @classmethod, это правильно!
if self.end_date < self.start_date:
raise ValueError('end_date должен быть после start_date')
return selfПочему?
mode='after'получает экземпляр (self), а не класс. Но дляmode='before'— нужен@classmethod!
@model_validator(mode='before')
@classmethod # ← Для 'before' ОБЯЗАТЕЛЬНО
def validate_before(cls, values):
return valuesПроблема: Валидатор зависит от другого поля, но оно ещё не валидировано.
class Order(BaseModel):
price: float
total: float
@field_validator('total', mode='after')
@classmethod
def calc_total(cls, v, info):
# ❌ info.data.get('price') может быть None!
# Pydantic валидирует поля в порядке объявления
return info.data.get('price', 0) * 2Решение: Объявляйте поля до валидаторов:
class Order(BaseModel):
price: float # 1. Валидируется первым
total: float # 2. Валидируется вторым
# Валидаторы запускаются в порядке объявления полей
@field_validator('price')
@classmethod
def validate_price(cls, v):
return v # price уже валидирован
@field_validator('total', mode='after')
@classmethod
def calc_total(cls, v, info):
# ✅ price уже в info.data
return info.data.get('price', 0) * 2Проблема:
@field_validator('email')
@classmethod
def validate_email(cls, v):
assert '@' in v # ❌ AssertionError не перехватывается Pydantic!
return vРешение:
@field_validator('email')
@classmethod
def validate_email(cls, v):
if '@' not in v:
raise ValueError('Email должен содержать @') # ✅
return vПочему? Pydantic ловит
ValueErrorиAssertionError, но сообщениеValueErrorбудет частью ValidationError, аAssertionErrorдаст менее понятную ошибку.
Цель: Создать модель UserRegistration с комплексной валидацией.
| Поле | Ограничения |
|---|---|
username | 3-20 символов, только буквы/цифры/подчёркивание |
email | Валидный email (содержит @ и домен) |
password | Минимум 8 символов, хотя бы 1 заглавная, 1 цифра |
password_confirm | Должен совпадать с password |
age | 18-100 |
from pydantic import BaseModel, Field, field_validator, model_validator
import re
class UserRegistration(BaseModel):
username: str
email: str
password: str
password_confirm: str
age: int = Field(ge=18, le=100, description="Возраст 18-100")
@field_validator('username')
@classmethod
def validate_username(cls, v: str) -> str:
"""Проверяет формат username."""
if len(v) < 3:
raise ValueError('Username минимум 3 символа')
if len(v) > 20:
raise ValueError('Username максимум 20 символов')
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Username: только буквы, цифры и подчёркивание')
return v
@field_validator('email')
@classmethod
def validate_email(cls, v: str) -> str:
"""Проверяет формат email."""
if '@' not in v:
raise ValueError('Email должен содержать @')
parts = v.split('@')
if len(parts) != 2 or not parts[1].contains('.'):
raise ValueError('Неверный формат email')
return v
@field_validator('password')
@classmethod
def validate_password(cls, v: str) -> str:
"""Проверяет сложность пароля."""
if len(v) < 8:
raise ValueError('Пароль минимум 8 символов')
if not any(c.isupper() for c in v):
raise ValueError('Пароль должен содержать заглавную букву')
if not any(c.isdigit() for c in v):
raise ValueError('Пароль должен содержать цифру')
return v
@model_validator(mode='after')
def check_passwords_match(self) -> 'UserRegistration':
"""Проверяет что пароли совпадают."""
if self.password != self.password_confirm:
raise ValueError('Пароли не совпадают')
return self# ✅ Валидная регистрация
reg = UserRegistration(
username="alice_dev",
email="alice@example.com",
password="SecurePass123",
password_confirm="SecurePass123",
age=25
)
print(reg.username) # alice_dev
# ❌ Короткий username
UserRegistration(
username="ab",
email="alice@example.com",
password="SecurePass123",
password_confirm="SecurePass123",
age=25
)
# ValidationError: Username минимум 3 символа
# ❌ Слабый пароль
UserRegistration(
username="alice_dev",
email="alice@example.com",
password="weak",
password_confirm="weak",
age=25
)
# ValidationError: Пароль минимум 8 символов
# ❌ Пароли не совпадают
UserRegistration(
username="alice_dev",
email="alice@example.com",
password="SecurePass123",
password_confirm="SecurePass456",
age=25
)
# ValidationError: Пароли не совпадают
# ❌ Возраст < 18
UserRegistration(
username="alice_dev",
email="alice@example.com",
password="SecurePass123",
password_confirm="SecurePass123",
age=16
)
# ValidationError: Input should be greater than or equal to 18ValueErrorValueError@ → ValueErrorValueErrorValueErrorValueError от FieldValueError от Fieldadmin, root, system)context={'strict': True}, требуйте спецсимвол в пароле@field_validator('password', mode='wrap') для проверки пароля до И после стандартной валидации| Что нужно проверить | Инструмент |
|---|---|
| Простое ограничение (min, max, length) | Field(...) |
| Формат одного поля (email, телефон) | @field_validator |
| Сложная логика одного поля (пароль) | @field_validator(mode='wrap') |
| Сравнение двух полей | @model_validator(mode='after') |
| Трансформация входных данных | @field_validator(mode='before') |
| Зависимость от внешнего контекста | @field_validator + ValidationInfo |
| Одинаковая логика для нескольких полей | @field_validator('поле1', 'поле2') |
| Концепция | Что научились делать |
|---|---|
| @field_validator | Писать кастомную валидацию для конкретных полей |
| mode='after' | Проверять значения после стандартной валидации |
| mode='before' | Парсить/трансформировать данные до Pydantic |
| mode='wrap' | Полный контроль с вызовом стандартной валидации |
| @model_validator | Проверять взаимосвязь полей модели |
| ValidationInfo | Получать доступ к другим полям и контексту |
| Комбинирование | Field + валидаторы для многоступенчатой проверки |
В следующей теме изучим сериализацию через model_dump и model_dump_json.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.