Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Кастомные валидаторы: field и model
custom_validators

Кастомные валидаторы: field и model

Написание собственных валидаторов, режимы before/after/wrap, валидация нескольких полей сразу.

Кастомные валидаторы: @field_validator и @model_validator

Когда встроенных ограничений Field недостаточно — пишем свои валидаторы


#Зачем нужны кастомные валидаторы?

#Проблема: 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: валидация одного поля

#Что это такое

@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Значение поля для проверкиНе будет доступа к значению
Обязательно вернуть vPydantic использует возвращаемое значениеПоле станет None
Ошибка = ValueErrorPydantic ловит только ValueErrorОшибка не будет перехвачена

Запомните: @field_validator получает значение одного поля. Он не знает о других полях модели (по умолчанию).


#Режимы валидации: before, after, wrap

Зачем это нужно: Режим определяет когда ваш валидатор запускается относительно стандартной валидации Pydantic.

#Таблица сравнения

РежимКогда запускаетсяЧто получаетДля чего
mode='after'После стандартной валидацииУже преобразованное значениеПроверка значений (возраст 0-150)
mode='before'До стандартной валидацииСырое входное значениеКастомный парсинг/трансформация
mode='wrap'Вместо стандартной валидацииСырое значение + handlerПолный контроль

#mode='after' (по умолчанию)

Когда использовать: Когда вам нужно проверить значение, которое уже преобразовано к правильному типу.

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

#mode='before'

Когда использовать: Когда нужно кастомно распарсить входные данные до того как 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.


#mode='wrap'

Когда использовать: Когда нужен полный контроль — проверить до И после стандартной валидации.

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 пробелов и т.д.)


#@model_validator: валидация всей модели

#Зачем это нужно

@field_validator проверяет одно поле. Но что если нужно сравнить два поля?

# ❌ ЭТО НЕ РАБОТАЕТ с @field_validator class Event(BaseModel): start_date: datetime end_date: datetime # Как проверить что end_date > start_date? # @field_validator не знает о другом поле!

Решение: @model_validator — получает всю модель целиком.


#mode='after': доступ ко всем полям

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).


#mode='before': валидация до создания модели

Когда использовать: Когда нужно проверить или изменить входные данные до того как 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 → модель НЕ создаётся

#Доступ к другим полям в field_validator

#ValidationInfo: мост между field_validator и model_validator

Иногда нужно проверить одно поле, но использовать значение другого. Для этого есть 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.


#ValidationInfo: контекст валидации

#Что такое контекст

Контекст — это внешние данные, которые передаются при валидации. Полезно когда логика зависит от роли пользователя, окружения и т.д.

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 и валидаторов

Лучшая практика: Используйте 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). Для этого нужен валидатор.


#Разбор типичных ошибок

#Ошибка 1: Забыли @classmethod

Симптом:

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 вызывает валидатор на классе модели, не на экземпляре. Без @classmethod Python пытается передать self как первый аргумент.


#Ошибка 2: Валидатор не возвращает значение

Симптом: Поле становится 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 # ← Обязательно!

#Ошибка 3: model_validator(mode='after') без @classmethod

Хорошая новость: Для 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

#Ошибка 4: Зависимые валидаторы в неправильном порядке

Проблема: Валидатор зависит от другого поля, но оно ещё не валидировано.

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

#Ошибка 5: ValueError vs AssertionError

Проблема:

@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 с комплексной валидацией.

#Требования

ПолеОграничения
username3-20 символов, только буквы/цифры/подчёркивание
emailВалидный email (содержит @ и домен)
passwordМинимум 8 символов, хотя бы 1 заглавная, 1 цифра
password_confirmДолжен совпадать с password
age18-100

#Шаг 1: Создайте модель

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

#Шаг 2: Протестируйте

# ✅ Валидная регистрация 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 18

#Критерии проверки

  • Модель создаётся с валидными данными
  • Короткий username → ValueError
  • Username со спецсимволами → ValueError
  • Email без @ → ValueError
  • Простой пароль → ValueError
  • Несовпадающие пароли → ValueError
  • Возраст < 18 → ValueError от Field
  • Возраст > 100 → ValueError от Field

#Бонусное задание (продвинутый уровень)

  1. Добавьте валидатор для проверки зарезервированных имён (admin, root, system)
  2. Добавьте контекст валидации: если context={'strict': True}, требуйте спецсимвол в пароле
  3. Реализуйте @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')

Далее: Сериализация: model_dump и model_dump_json