Перейти к содержимому

ConfigDict, три инцидента и валидатор, который ходил в сеть

Константин Потапов
45 min

Цена поехала из-за locale. Feature flags включились все сразу. В батче потекла память. Дальше ConfigDict, четыре уровня валидации и то, что I/O в валидаторе делает с event loop.

В первой части 28 моделей переехали на v2. Здесь то, из-за чего они потом ломались уже на v2.

strict=False съел цену: "1.234,56" в немецкой locale стал не тем Decimal. Feature flags из Redis пришли строками, и включились все сразу. В батче забыли model_construct() и память поехала. По моим инцидентам около 40% багов с Pydantic сидели в ConfigDict, не в полях.

strict=True на финансах и конфигах. extra='forbid' на входе API. validate_assignment=True только если модель потом мутируют: плюс 68% к записи. I/O в валидаторе не делаю. На границе системы проверяю, внутри собираю через model_construct().

Читать можно с любого раздела. Код не трогал.

ConfigDict

Это контракт поведения модели. Неправильный контракт даёт тихую порчу данных, не красивый traceback.

Базовый шаблон для production моделей

from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
from uuid import UUID
 
class ProductionBaseModel(BaseModel):
    """Базовый класс для всех production моделей.
 
    Наследуйте от него для консистентной конфигурации."""
 
    model_config = ConfigDict(
        # Валидация
        strict=False,  # Обсудим trade-offs ниже
        validate_default=True,
        validate_assignment=False,  # Включать только если нужно
 
        # Дополнительные поля
        extra='forbid',  # Строго для API
 
        # ORM
        from_attributes=True,
 
        # Сериализация
        ser_json_timedelta='float',
        ser_json_bytes='base64',
        use_enum_values=True,
 
        # Производительность
        # revalidate_instances='never',  # Только если нужна максимальная скорость
    )
 
    # Стандартные поля
    id: UUID
    created_at: datetime
    updated_at: datetime | None = None

Параметр strict: когда включать и почему

Проблема: По умолчанию Pydantic приводит типы агрессивно.

from pydantic import BaseModel
 
class Config(BaseModel):
    debug: bool
    timeout: int
 
# Проблемные кейсы без strict mode
config = Config(debug="false", timeout="30")
print(config.debug)  # True! Любая непустая строка = True
print(config.timeout)  # 30 (int), OK, но неявно
 
# С потерей точности
class Payment(BaseModel):
    amount_cents: int
 
payment = Payment(amount_cents="100.99")
print(payment.amount_cents)  # 100, потеряли .99 без ошибки!

Решение: Три стратегии strict mode:

# Стратегия 1: Глобальный strict для критичных моделей
class PaymentRequest(BaseModel):
    model_config = ConfigDict(strict=True)
 
    amount_cents: int
    currency: str
 
# Стратегия 2: Per-field strict для гибкости
class MixedModel(BaseModel):
    # Критичные поля, строгие
    price: Decimal = Field(strict=True)
    quantity: int = Field(strict=True)
 
    # User input, гибкий (API удобство)
    search_query: str  # Примет и int, приведёт к str
 
# Стратегия 3: Явная валидация в @field_validator
class SmartModel(BaseModel):
    value: int
 
    @field_validator('value', mode='before')
    @classmethod
    def parse_value(cls, v):
        # Контролируем приведение явно
        if isinstance(v, str):
            if not v.isdigit():
                raise ValueError(f'Invalid int string: {v}')
            return int(v)
        if isinstance(v, (int, float)):
            return int(v)
        raise ValueError(f'Cannot convert {type(v)} to int')

Наша стратегия в production:

Тип моделиstrictПочему
Payment, финансыTrueТочность критична
КонфигиTrueFeature flags инцидент (см. часть 1)
API request/responseFalseУдобство клиентов
Internal DTOsFalseГибкость
Database modelsFalseORM может возвращать разные типы

Параметр extra: защита от ошибок клиентов

from pydantic import BaseModel, ConfigDict, ValidationError
 
# Опция 1: forbid (рекомендуем для API)
class StrictAPI(BaseModel):
    model_config = ConfigDict(extra='forbid')
 
    username: str
    email: str
 
try:
    user = StrictAPI(username="alice", email="a@ex.com", emial="typo@ex.com")
except ValidationError as e:
    print(e)
    # 1 validation error for StrictAPI
    # emial
    #   Extra inputs are not permitted
 
# Опция 2: allow (опасно, но иногда нужно)
class FlexibleAPI(BaseModel):
    model_config = ConfigDict(extra='allow')
 
    username: str
    email: str
 
user = FlexibleAPI(username="alice", email="a@ex.com", custom_field="data")
print(user.custom_field)  # "data", сохранилось, но не типизировано!
 
# Опция 3: ignore (default в v2)
class DefaultAPI(BaseModel):
    username: str
    email: str
 
user = DefaultAPI(username="alice", email="a@ex.com", emial="typo")
# Создаётся без ошибки, emial молча игнорируется

Когда что использовать:

# forbid, для публичных API (защита от опечаток клиентов)
class PublicAPIRequest(BaseModel):
    model_config = ConfigDict(extra='forbid')
 
# allow, для legacy API с обратной совместимостью
class LegacyAPIRequest(BaseModel):
    model_config = ConfigDict(extra='allow')
 
    # Известные поля
    user_id: int
    # Неизвестные поля сохраняются для backward compatibility
 
# ignore, для internal services (гибкость + не ломаем при добавлении полей)
class InternalMessage(BaseModel):
    model_config = ConfigDict(extra='ignore')

Параметр validate_assignment: мутабельность с гарантиями

from pydantic import BaseModel, ConfigDict, Field
 
class Product(BaseModel):
    model_config = ConfigDict(validate_assignment=True)
 
    name: str
    price: float = Field(gt=0)  # Должна быть > 0
    stock: int = Field(ge=0)  # Должен быть >= 0
 
product = Product(name="Laptop", price=1000.0, stock=10)
 
# С validate_assignment=True, валидация при изменении
try:
    product.price = -100.0
except ValidationError:
    print("Cannot set negative price")  # Поймали!
 
# Без validate_assignment=True
class UnsafeProduct(BaseModel):
    name: str
    price: float = Field(gt=0)
 
unsafe = UnsafeProduct(name="Laptop", price=1000.0)
unsafe.price = -100.0  # Принято без проверки!
print(unsafe.price)  # -100.0, инвариант нарушен

Trade-offs:

validate_assignmentПлюсыМинусыКогда использовать
TrueГарантия инвариантовOverhead на каждое присваиваниеLong-lived объекты (cache, sessions)
False (default)ПроизводительностьМожно сломать инвариантыDTO, immutable данные

Паттерн: Immutable где возможно

# Лучше чем validate_assignment, сделать immutable
class ImmutableProduct(BaseModel):
    model_config = ConfigDict(frozen=True)
 
    name: str
    price: float = Field(gt=0)
 
product = ImmutableProduct(name="Laptop", price=1000.0)
 
# Любая попытка изменить, ошибка
try:
    product.price = 1500.0
except ValidationError:
    print("Cannot modify frozen model")
 
# Для изменений, создаём новый объект
updated = product.model_copy(update={"price": 1500.0})  # Валидация при создании

Полная таблица параметров ConfigDict

ПараметрDefaultТипОписаниеКогда использовать
strictFalseboolСтрогая типизация без приведенияФинансы, конфиги, критичные данные
extra'ignore'strПоведение с лишними полями'forbid' для API, 'allow' для legacy
validate_defaultFalseboolВалидировать default значенияЕсли defaults динамические или могут быть невалидными
validate_assignmentFalseboolВалидация при model.field = valueLong-lived мутабельные объекты
from_attributesFalseboolСоздание из объектов (ORM)SQLAlchemy, Django ORM
populate_by_nameFalseboolПринимать и alias и реальное имяОбратная совместимость API
use_enum_valuesFalseboolСериализовать Enum как .valueAPI responses (JSON не поддерживает Enum)
frozenFalseboolImmutable модельDTO, events, value objects
arbitrary_types_allowedFalseboolРазрешить нестандартные типыИнтеграция с внешними библиотеками (осторожно!)
ser_json_timedelta'iso8601'strФормат timedelta в JSON'float' для удобства клиентов
ser_json_bytes'utf8'strФормат bytes в JSON'base64' для бинарных данных
revalidate_instances'always'strПеревалидация вложенных моделей'never' для максимальной скорости (осторожно!)
protected_namespaces('model_',)tupleЗапрещённые префиксы полейПредотвращение конфликтов с Pydantic API

Production инциденты: разбор реальных кейсов

Инцидент #1: Ценовая дискрепанция из-за locale

Дата: 2024-03-15 Сервис: Billing Service Impact: 0.01% расхождение в ценах для европейских клиентов

Проблема:

# Модель инвойса
class Invoice(BaseModel):
    amount: Decimal
    currency: str
 
# Европейские клиенты отправляют: "1.234,56" (EU format)
# Американские клиенты: "1,234.56" (US format)
 
# С strict=False:
Invoice(amount="1.234,56", currency="EUR")  # Decimal('1.234') ← потеря .56!
Invoice(amount="1,234.56", currency="USD")  # Decimal('1.23456') ← неправильный парсинг!

Root cause: Pydantic v2 использует Python's Decimal(str), который не учитывает locale. Любая запятая/точка интерпретируется по правилам Python (точка = decimal separator).

Решение:

from decimal import Decimal
from pydantic import field_validator, ValidationInfo
 
class Invoice(BaseModel):
    model_config = ConfigDict(strict=True)  # Только точные типы
 
    amount: Decimal
    currency: str
 
    @field_validator('amount', mode='before')
    @classmethod
    def parse_amount_with_locale(cls, v, info: ValidationInfo):
        """Парсинг цены с учётом locale клиента."""
        if isinstance(v, str):
            # Получаем locale из context (передаём из API endpoint)
            locale = info.context.get('locale', 'en_US') if info.context else 'en_US'
 
            if locale.startswith('de') or locale.startswith('fr'):  # EU format
                # "1.234,56" → "1234.56"
                v = v.replace('.', '').replace(',', '.')
            else:  # US format
                # "1,234.56" → "1234.56"
                v = v.replace(',', '')
 
            return Decimal(v)
 
        return v
 
# В API endpoint передаём locale
@app.post("/invoices")
def create_invoice(invoice_data: dict, locale: str = Header(default="en_US")):
    invoice = Invoice.model_validate(
        invoice_data,
        context={'locale': locale}
    )
    return invoice

Метрики после фикса:

  • Ценовые расхождения: 0.01% → 0% ✅
  • Время валидации: +2ms (+5% overhead, приемлемо)
  • Кол-во инцидентов: 0 за 6 месяцев

Инцидент #2: Feature flags включились все сразу

Дата: 2024-06-20 Сервис: API Gateway Impact: 3 минуты downtime на staging, production не затронут

Проблема:

class FeatureFlags(BaseModel):
    new_checkout: bool
    beta_ui: bool
    debug_mode: bool
 
# В Redis хранились строки "true"/"false"
flags_data = redis.hgetall("feature_flags")
# {'new_checkout': 'false', 'beta_ui': 'false', 'debug_mode': 'false'}
 
flags = FeatureFlags(**flags_data)
print(flags.new_checkout)  # True ← ЧТО?!
print(flags.beta_ui)       # True
print(flags.debug_mode)    # True

Root cause: В Python bool("false") == True (любая непустая строка = True). Pydantic v2 с strict=False использует нативное приведение Python.

Решение:

class FeatureFlags(BaseModel):
    model_config = ConfigDict(strict=True)  # Требуем точные типы
 
    new_checkout: bool
    beta_ui: bool
    debug_mode: bool
 
# В коде загрузки явно конвертируем
def load_flags(redis_client) -> FeatureFlags:
    raw_data = redis_client.hgetall("feature_flags")
 
    # Явное приведение строк в bool
    parsed_data = {
        k: v.lower() == 'true' if isinstance(v, str) else v
        for k, v in raw_data.items()
    }
 
    return FeatureFlags(**parsed_data)

Альтернативное решение (если нельзя изменить storage):

class FeatureFlags(BaseModel):
    new_checkout: bool
    beta_ui: bool
    debug_mode: bool
 
    @model_validator(mode='before')
    @classmethod
    def parse_string_bools(cls, data):
        """Конвертируем строковые boolean из Redis."""
        if isinstance(data, dict):
            for key, value in data.items():
                if isinstance(value, str):
                    if value.lower() in ('true', '1', 'yes', 'on'):
                        data[key] = True
                    elif value.lower() in ('false', '0', 'no', 'off'):
                        data[key] = False
        return data

Метрики после фикса:

  • False positives: 0 за 8 месяцев ✅
  • Staging incidents prevented: 1
  • Production impact: None (caught in staging)

Инцидент #3: Memory leak в batch processing

Дата: 2024-09-10 Сервис: Data Processor Impact: OOMKilled после 6 часов работы, потеря 2 часов вычислений

Проблема:

class DataRecord(BaseModel):
    id: int
    timestamp: datetime
    values: list[float]
    metadata: dict
 
# Обработка 500k записей
records = [DataRecord(**row) for row in csv_reader]  # Peak memory: 4.2GB

Root cause: Pydantic v2 кэширует метаданные валидации для производительности. При массовом создании объектов память не освобождалась сразу из-за cyclic references в validator cache.

Решение:

# Вариант 1: model_construct для trusted данных
def process_csv_fast(csv_path: str):
    # Валидируем структуру файла один раз
    with open(csv_path) as f:
        reader = csv.DictReader(f)
        headers = reader.fieldnames
 
        expected = set(DataRecord.model_fields.keys())
        if not expected.issubset(headers):
            raise ValueError(f"Invalid CSV structure")
 
        # Создаём без валидации (данные проверены на уровне схемы)
        records = [
            DataRecord.model_construct(**row)
            for row in reader
        ]
 
    return records  # Peak memory: 890MB (в 4.7x меньше)
 
# Вариант 2: Batch processing с явной очисткой
import gc
 
def process_csv_batched(csv_path: str, batch_size: int = 10_000):
    results = []
 
    with open(csv_path) as f:
        reader = csv.DictReader(f)
        batch = []
 
        for i, row in enumerate(reader):
            batch.append(DataRecord(**row))
 
            if len(batch) >= batch_size:
                # Обрабатываем batch
                results.extend(process_batch(batch))
 
                # Явно очищаем память
                batch.clear()
                gc.collect()
 
    return results  # Peak memory: 1.2GB (в 3.5x меньше)

Метрики после фикса:

  • Peak memory: 4.2GB → 890MB (в 4.7x меньше) ✅
  • Processing time: 45 минут → 12 минут (в 3.75x быстрее) ✅
  • OOM incidents: 0 за 4 месяца

Четыре уровня валидации

Уровень 1: Field constraints (декларативная валидация)

from pydantic import BaseModel, Field
from typing import Annotated
from datetime import datetime
 
class User(BaseModel):
    # Строки
    username: str = Field(min_length=3, max_length=20, pattern=r'^[a-zA-Z0-9_]+$')
    email: str = Field(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')
 
    # Числа
    age: int = Field(ge=18, le=120)
    rating: float = Field(gt=0, le=5)
 
    # Даты
    birth_date: datetime = Field(le=datetime.now())  # Не в будущем
 
    # Коллекции
    tags: list[str] = Field(min_length=1, max_length=10)
    metadata: dict = Field(default_factory=dict)
 
# Переиспользуемые типы через Annotated
Username = Annotated[str, Field(min_length=3, max_length=20, pattern=r'^[a-zA-Z0-9_]+$')]
Email = Annotated[str, Field(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')]
Age = Annotated[int, Field(ge=18, le=120)]
 
class CompactUser(BaseModel):
    username: Username
    email: Email
    age: Age

Уровень 2: field_validator (императивная валидация)

Критически важно: порядок выполнения

Input data → @field_validator(mode='before') → Type parsing → Field constraints → @field_validator(mode='after') → @model_validator
from pydantic import BaseModel, field_validator
from typing import Any
 
class Product(BaseModel):
    name: str
    price: float
    discount_percent: float = 0.0
 
    @field_validator('price', mode='before')
    @classmethod
    def parse_price(cls, v: Any) -> Any:
        """Парсинг цены из разных форматов ДО type checking."""
        # mode='before' получает сырые данные (может быть str, int, float, etc.)
        if isinstance(v, str):
            # Убираем символы валюты и пробелы
            cleaned = v.replace('$', '').replace('', '').replace(' ', '').replace(',', '')
            return float(cleaned)
        return v
 
    @field_validator('price', mode='after')
    @classmethod
    def round_price(cls, v: float) -> float:
        """Округление ПОСЛЕ type checking и Field constraints."""
        # mode='after' получает уже проверенный float
        return round(v, 2)
 
    @field_validator('discount_percent', mode='after')
    @classmethod
    def validate_discount(cls, v: float) -> float:
        if not 0 <= v <= 100:
            raise ValueError('Discount must be between 0 and 100')
        return v
 
# Примеры использования
product = Product(name="Laptop", price="$1,299.99", discount_percent=10)
print(product.price)  # 1299.99
 
# Валидация сработает
try:
    Product(name="Laptop", price="$1,299.99", discount_percent=150)
except ValidationError as e:
    print(e)  # Discount must be between 0 and 100

Важный кейс: Валидация зависимостей между полями (mode='after' vs @model_validator)

from pydantic import BaseModel, field_validator, model_validator
 
# ❌ Неправильно: пытаемся сравнить поля в field_validator
class WrongOrder(BaseModel):
    start_date: datetime
    end_date: datetime
 
    @field_validator('end_date', mode='after')
    @classmethod
    def validate_end_after_start(cls, v: datetime) -> datetime:
        # Проблема: доступа к start_date здесь нет!
        # Это field_validator, он видит только одно поле
        return v
 
# ✅ Правильно: используем model_validator для cross-field validation
class CorrectOrder(BaseModel):
    start_date: datetime
    end_date: datetime
 
    @model_validator(mode='after')
    def validate_dates_order(self):
        # Теперь видим все поля модели
        if self.end_date < self.start_date:
            raise ValueError('end_date must be after start_date')
        return self

Уровень 3: Кастомные типы с полным контролем

from typing import Annotated
from pydantic import BaseModel, AfterValidator, BeforeValidator, WrapValidator
import re
 
# Простая валидация через функцию
def validate_phone(v: str) -> str:
    digits = re.sub(r'\D', '', v)
    if len(digits) != 11 or not digits.startswith('7'):
        raise ValueError('Phone must be 11 digits starting with 7')
    return f'+7 ({digits[1:4]}) {digits[4:7]}-{digits[7:9]}-{digits[9:]}'
 
PhoneNumber = Annotated[str, AfterValidator(validate_phone)]
 
# Продвинутый кейс: WrapValidator для полного контроля
from pydantic_core import core_schema
from pydantic import GetCoreSchemaHandler
 
class StrictEmail(str):
    """Email с валидацией на Pydantic core уровне (Rust)."""
 
    @classmethod
    def __get_pydantic_core_schema__(cls, source_type, handler: GetCoreSchemaHandler):
        # Получаем стандартную схему для str
        str_schema = handler(str)
 
        return core_schema.no_info_after_validator_function(
            cls._validate,
            str_schema,
            serialization=core_schema.plain_serializer_function_ser_schema(
                lambda x: str(x)
            )
        )
 
    @classmethod
    def _validate(cls, v: str) -> "StrictEmail":
        # Валидация email
        if '@' not in v or '.' not in v.split('@')[1]:
            raise ValueError('Invalid email format')
 
        # Дополнительная проверка: нет одноразовых email
        disposable_domains = ['temp-mail.org', '10minutemail.com']
        domain = v.split('@')[1]
        if domain in disposable_domains:
            raise ValueError(f'Disposable email domains not allowed: {domain}')
 
        return cls(v)
 
class User(BaseModel):
    phone: PhoneNumber
    email: StrictEmail
 
# Использование
user = User(phone="8-900-123-45-67", email="user@example.com")
print(user.phone)  # +7 (900) 123-45-67

Уровень 4: Runtime context для валидации

Важно: Избегайте I/O операций в валидаторах, это антипаттерн (см. критику выше).

from pydantic import BaseModel, model_validator, ValidationInfo
 
# ✅ Правильно: используем context для runtime данных БЕЗ I/O
class Article(BaseModel):
    title: str
    author_id: int
    is_published: bool
 
    @model_validator(mode='after')
    def check_permissions(self, info: ValidationInfo):
        """Проверка прав на основе context (без I/O)."""
        if not info.context:
            return self
 
        current_user_id = info.context.get('current_user_id')
        is_admin = info.context.get('is_admin', False)
 
        if self.is_published:
            if not is_admin and self.author_id != current_user_id:
                raise ValueError('Only author or admin can publish')
 
        return self
 
# В FastAPI endpoint
@app.post("/articles")
async def create_article(
    article_data: dict,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    # Валидация схемы + permissions через context
    article = Article.model_validate(
        article_data,
        context={
            "current_user_id": current_user.id,
            "is_admin": current_user.is_admin
        }
    )
 
    # I/O операции после валидации схемы
    db.add(Article(**article.model_dump()))
    db.commit()

Паттерн: Разделение concerns для I/O валидации

# ❌ Антипаттерн: I/O в валидаторе
class OrderCreate(BaseModel):
    product_id: int
 
    @field_validator('product_id')
    @classmethod
    def product_exists(cls, v: int) -> int:
        # Блокирующий I/O в валидаторе!
        product = db.query(Product).get(v)  # ← НЕ ДЕЛАЙТЕ ТАК
        if not product:
            raise ValueError(f'Product {v} not found')
        return v
 
# ✅ Правильно: I/O через dependency injection
from fastapi import Depends, HTTPException
 
async def validate_product_exists(
    product_id: int,
    db: Session = Depends(get_db)
) -> Product:
    """Dependency для проверки существования продукта."""
    product = await db.get(Product, product_id)
    if not product:
        raise HTTPException(404, f"Product {product_id} not found")
    if not product.is_available:
        raise HTTPException(400, f"Product {product_id} is not available")
    return product
 
@app.post("/orders")
async def create_order(
    order_data: OrderCreate,  # Pydantic валидирует схему
    product: Product = Depends(validate_product_exists)  # I/O отдельно
):
    # Теперь product уже проверен и загружен
    order = Order(product_id=product.id, ...)
    return order

Ошибки, которые видит клиент

Проблема: дефолтные ошибки Pydantic, не для пользователей

from pydantic import BaseModel, ValidationError
 
class User(BaseModel):
    email: str
    age: int
 
try:
    User(email="invalid", age="not a number")
except ValidationError as e:
    print(e)
 
# Output:
# 2 validation errors for User
# email
#   value is not a valid email address (type=value_error.email)
# age
#   value is not a valid integer (type=type_error.integer)

Проблемы дефолтного вывода:

  1. Технические детали (type=value_error.email), не понятны пользователям
  2. Нет локализации
  3. Нет mapping на конкретные поля формы (для фронтенда)
  4. Не логируется для аналитики

Паттерн 1: Структурированные ошибки для API

from pydantic import BaseModel, ValidationError, Field
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from typing import Any
 
app = FastAPI()
 
class ErrorResponse(BaseModel):
    """Стандартный формат ошибок API."""
    error_code: str
    message: str
    details: list[dict[str, Any]] | None = None
 
class FieldError(BaseModel):
    """Ошибка конкретного поля."""
    field: str
    message: str
    value: Any = None
 
def format_validation_error(exc: ValidationError) -> ErrorResponse:
    """Преобразует Pydantic ValidationError в user-friendly формат."""
    field_errors = []
 
    for error in exc.errors():
        # error['loc'], путь к полю, например ('email',) или ('address', 'city')
        field_path = '.'.join(str(loc) for loc in error['loc'])
 
        # Человекочитаемые сообщения
        message = get_user_friendly_message(error)
 
        field_errors.append(FieldError(
            field=field_path,
            message=message,
            value=error.get('input')
        ))
 
    return ErrorResponse(
        error_code="VALIDATION_ERROR",
        message="Проверьте правильность заполнения полей",
        details=[err.model_dump() for err in field_errors]
    )
 
def get_user_friendly_message(error: dict) -> str:
    """Маппинг Pydantic error types в понятные сообщения."""
    error_type = error['type']
 
    messages = {
        'string_type': 'Должно быть текстом',
        'int_type': 'Должно быть целым числом',
        'float_type': 'Должно быть числом',
        'value_error.email': 'Неверный формат email',
        'value_error.url': 'Неверный формат URL',
        'string_too_short': lambda ctx: f'Минимум {ctx.get("min_length")} символов',
        'string_too_long': lambda ctx: f'Максимум {ctx.get("max_length")} символов',
        'string_pattern_mismatch': 'Неверный формат',
        'greater_than': lambda ctx: f'Должно быть больше {ctx.get("gt")}',
        'greater_than_equal': lambda ctx: f'Должно быть не меньше {ctx.get("ge")}',
        'less_than': lambda ctx: f'Должно быть меньше {ctx.get("lt")}',
        'less_than_equal': lambda ctx: f'Должно быть не больше {ctx.get("le")}',
        'missing': 'Обязательное поле',
    }
 
    msg = messages.get(error_type, 'Некорректное значение')
 
    # Если сообщение, функция, вызываем с контекстом
    if callable(msg):
        return msg(error.get('ctx', {}))
 
    return msg
 
# Использование в FastAPI
@app.exception_handler(ValidationError)
async def validation_exception_handler(request, exc: ValidationError):
    """Глобальный handler для ValidationError."""
    error_response = format_validation_error(exc)
 
    return JSONResponse(
        status_code=422,
        content=error_response.model_dump()
    )
 
@app.post("/users")
def create_user(user: User):
    # Валидация происходит автоматически через FastAPI
    return user
 
# Пример ответа при ошибке:
# {
#   "error_code": "VALIDATION_ERROR",
#   "message": "Проверьте правильность заполнения полей",
#   "details": [
#     {
#       "field": "email",
#       "message": "Неверный формат email",
#       "value": "invalid"
#     },
#     {
#       "field": "age",
#       "message": "Должно быть целым числом",
#       "value": "not a number"
#     }
#   ]
# }

Паттерн 2: Логирование validation errors для аналитики

import logging
import structlog
from prometheus_client import Counter
 
logger = structlog.get_logger()
 
# Prometheus метрики
validation_errors_total = Counter(
    'validation_errors_total',
    'Total validation errors',
    ['model_name', 'field', 'error_type']
)
 
def log_validation_error(exc: ValidationError, model_name: str, context: dict = None):
    """Структурированное логирование ошибок валидации."""
    for error in exc.errors():
        field_path = '.'.join(str(loc) for loc in error['loc'])
 
        # Структурированный лог
        logger.warning(
            "validation_error",
            model=model_name,
            field=field_path,
            error_type=error['type'],
            input_value=error.get('input'),
            context=context or {}
        )
 
        # Метрики для мониторинга
        validation_errors_total.labels(
            model_name=model_name,
            field=field_path,
            error_type=error['type']
        ).inc()
 
# Использование
@app.post("/orders")
def create_order(order_data: dict, user_id: int = Depends(get_current_user_id)):
    try:
        order = Order(**order_data)
    except ValidationError as e:
        # Логируем для аналитики
        log_validation_error(
            e,
            model_name="Order",
            context={
                "user_id": user_id,
                "endpoint": "/orders",
                "ip": request.client.host
            }
        )
 
        # Возвращаем user-friendly ошибку
        raise HTTPException(
            status_code=422,
            detail=format_validation_error(e).model_dump()
        )
 
    return order

Паттерн 3: Частичная валидация (продолжить несмотря на ошибки)

from pydantic import BaseModel, ValidationError
from typing import TypeVar, Generic
 
T = TypeVar('T')
 
class PartialResult(Generic[T]):
    """Результат частичной валидации."""
    valid: list[T]
    invalid: list[dict]
 
def validate_partial(
    model: type[BaseModel],
    data: list[dict]
) -> PartialResult:
    """Валидирует список, собирая и валидные и невалидные записи."""
    valid = []
    invalid = []
 
    for i, row in enumerate(data):
        try:
            obj = model(**row)
            valid.append(obj)
        except ValidationError as e:
            invalid.append({
                'index': i,
                'data': row,
                'errors': e.errors()
            })
 
    return PartialResult(valid=valid, invalid=invalid)
 
# Пример: импорт CSV с частичной валидацией
@app.post("/import/users")
def import_users(file: UploadFile):
    # Читаем CSV
    rows = list(csv.DictReader(file.file))
 
    # Валидируем частично
    result = validate_partial(User, rows)
 
    # Сохраняем валидные
    db.bulk_insert_mappings(User, [u.model_dump() for u in result.valid])
 
    # Возвращаем отчёт
    return {
        "imported": len(result.valid),
        "failed": len(result.invalid),
        "errors": result.invalid[:10]  # Первые 10 ошибок
    }

Паттерн 4: Retry с коррекцией данных

from pydantic import BaseModel, ValidationError, field_validator
 
class UserRegistration(BaseModel):
    email: str
    phone: str
 
    @field_validator('email', mode='before')
    @classmethod
    def normalize_email(cls, v):
        """Нормализация email с автокоррекцией частых ошибок."""
        if isinstance(v, str):
            v = v.strip().lower()
 
            # Автокоррекция частых опечаток
            typos = {
                'gmial.com': 'gmail.com',
                'gmai.com': 'gmail.com',
                'yahooo.com': 'yahoo.com',
                'hotmial.com': 'hotmail.com'
            }
 
            for typo, correct in typos.items():
                v = v.replace(typo, correct)
 
        return v
 
    @field_validator('phone', mode='before')
    @classmethod
    def normalize_phone(cls, v):
        """Нормализация телефона, убираем всё кроме цифр."""
        if isinstance(v, str):
            # Убираем пробелы, тире, скобки
            v = ''.join(filter(str.isdigit, v))
 
            # Добавляем +7 для российских номеров
            if len(v) == 10:
                v = f'+7{v}'
            elif len(v) == 11 and v.startswith('7'):
                v = f'+{v}'
            elif len(v) == 11 and v.startswith('8'):
                v = f'+7{v[1:]}'
 
        return v
 
# Пример работы автокоррекции
user = UserRegistration(
    email="  User@GMIal.COM  ",  # → user@gmail.com
    phone="8 (900) 123-45-67"     # → +79001234567
)

Мониторинг: Grafana dashboard для validation errors

# Query 1: Top 10 полей с ошибками
topk(10,
  sum by (field) (
    rate(validation_errors_total[5m])
  )
)
 
# Query 2: Распределение типов ошибок
sum by (error_type) (
  rate(validation_errors_total[1h])
)
 
# Query 3: Spike detection (аномальный рост ошибок)
rate(validation_errors_total[5m]) > 2 * rate(validation_errors_total[1h] offset 1d)
 
# Alert rule: слишком много ошибок валидации
ALERT HighValidationErrorRate
  IF rate(validation_errors_total[5m]) > 100
  FOR 5m
  ANNOTATIONS {
    summary = "High validation error rate",
    description = "{{ $value }} validation errors per second"
  }

Сложные правила

Паттерн 1: Conditional Validation (валидация зависит от других полей)

from pydantic import BaseModel, model_validator, Field
from typing import Literal
 
class PaymentMethod(BaseModel):
    """Модель с условной валидацией на основе типа платежа."""
    type: Literal["card", "bank_transfer", "crypto"]
 
    # Поля для card
    card_number: str | None = None
    cvv: str | None = None
    expiry: str | None = None
 
    # Поля для bank_transfer
    iban: str | None = None
    swift: str | None = None
 
    # Поля для crypto
    wallet_address: str | None = None
    network: str | None = None
 
    @model_validator(mode='after')
    def validate_payment_fields(self):
        """Проверяем, что заполнены нужные поля в зависимости от типа."""
        if self.type == "card":
            if not all([self.card_number, self.cvv, self.expiry]):
                raise ValueError(
                    "Card payment requires card_number, cvv, and expiry"
                )
            # Валидация карты
            if len(self.card_number.replace(" ", "")) != 16:
                raise ValueError("Card number must be 16 digits")
 
        elif self.type == "bank_transfer":
            if not all([self.iban, self.swift]):
                raise ValueError(
                    "Bank transfer requires iban and swift"
                )
 
        elif self.type == "crypto":
            if not all([self.wallet_address, self.network]):
                raise ValueError(
                    "Crypto payment requires wallet_address and network"
                )
 
        return self
 
# Использование
card_payment = PaymentMethod(
    type="card",
    card_number="1234 5678 9012 3456",
    cvv="123",
    expiry="12/25"
)
 
crypto_payment = PaymentMethod(
    type="crypto",
    wallet_address="0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1",
    network="ethereum"
)

Паттерн 2: Dynamic Validation Rules (feature flags)

from pydantic import BaseModel, field_validator, ValidationInfo
from typing import Any
 
class RegistrationRequest(BaseModel):
    """Регистрация с динамическими правилами валидации."""
    username: str
    email: str
    password: str
    age: int
 
    @field_validator('password')
    @classmethod
    def validate_password_strength(cls, v: str, info: ValidationInfo) -> str:
        """Валидация пароля с учётом feature flags."""
        # Получаем feature flags из context
        if not info.context:
            return v
 
        strict_mode = info.context.get('strict_password_policy', False)
 
        if strict_mode:
            # Строгие требования
            if len(v) < 12:
                raise ValueError('Password must be at least 12 characters (strict mode)')
 
            if not any(c.isupper() for c in v):
                raise ValueError('Password must contain uppercase letter')
 
            if not any(c.isdigit() for c in v):
                raise ValueError('Password must contain digit')
 
            if not any(c in '!@#$%^&*' for c in v):
                raise ValueError('Password must contain special character')
        else:
            # Базовые требования
            if len(v) < 8:
                raise ValueError('Password must be at least 8 characters')
 
        return v
 
# В API endpoint передаём feature flags
@app.post("/register")
def register(
    data: dict,
    strict_mode: bool = Depends(get_strict_mode_flag)
):
    user = RegistrationRequest.model_validate(
        data,
        context={'strict_password_policy': strict_mode}
    )
    return user

Паттерн 3: Cascading Validation (каскадная валидация бизнес-правил)

from pydantic import BaseModel, model_validator
from datetime import datetime, timedelta
 
class Subscription(BaseModel):
    """Подписка с каскадной валидацией."""
    plan: Literal["free", "basic", "premium"]
    start_date: datetime
    end_date: datetime | None = None
    auto_renew: bool = False
    promo_code: str | None = None
 
    @model_validator(mode='after')
    def validate_subscription_rules(self):
        """Каскадная проверка правил подписки."""
        # Правило 1: end_date должна быть после start_date
        if self.end_date and self.end_date <= self.start_date:
            raise ValueError('end_date must be after start_date')
 
        # Правило 2: Free план не может быть auto_renew
        if self.plan == "free" and self.auto_renew:
            raise ValueError('Free plan cannot have auto_renew enabled')
 
        # Правило 3: Promo code только для платных планов
        if self.plan == "free" and self.promo_code:
            raise ValueError('Free plan cannot use promo codes')
 
        # Правило 4: Premium требует минимум 1 год подписки
        if self.plan == "premium" and self.end_date:
            duration = self.end_date - self.start_date
            if duration < timedelta(days=365):
                raise ValueError('Premium plan requires minimum 1 year subscription')
 
        return self

Паттерн 4: Distributed Validation (проверка через внешние сервисы)

from pydantic import BaseModel, field_validator
import httpx
from functools import lru_cache
 
class OrderCreate(BaseModel):
    """Заказ с проверкой через внешние сервисы."""
    product_id: str
    quantity: int
    shipping_address: str
    promo_code: str | None = None
 
    @field_validator('promo_code')
    @classmethod
    def validate_promo_code(cls, v: str | None) -> str | None:
        """Валидация промокода через promo-service."""
        if v is None:
            return v
 
        # ⚠️ Внимание: I/O в валидаторе, используйте осторожно!
        # Лучше проверять промокод в endpoint, а не здесь
 
        # Если всё же нужно здесь, используйте кэш
        if not is_promo_code_valid(v):
            raise ValueError(f'Invalid promo code: {v}')
 
        return v
 
@lru_cache(maxsize=1000)
def is_promo_code_valid(code: str) -> bool:
    """Проверка промокода с кэшированием."""
    try:
        # Синхронный HTTP запрос к promo-service
        response = httpx.get(
            f"http://promo-service/validate/{code}",
            timeout=1.0  # Короткий timeout чтобы не блокировать
        )
        return response.status_code == 200
    except httpx.TimeoutException:
        # Fail open: если сервис недоступен, пропускаем
        return True
    except Exception:
        return False

Лучший паттерн (без I/O в валидаторе):

from fastapi import Depends, HTTPException
 
# ✅ Правильно: I/O через dependency injection
class OrderCreate(BaseModel):
    """Заказ без I/O в валидаторе."""
    product_id: str
    quantity: int
    shipping_address: str
    promo_code: str | None = None
 
async def validate_promo_code(
    order: OrderCreate,
    promo_service: PromoService = Depends(get_promo_service)
) -> OrderCreate:
    """Dependency для async проверки промокода."""
    if order.promo_code:
        is_valid = await promo_service.validate(order.promo_code)
        if not is_valid:
            raise HTTPException(400, f"Invalid promo code: {order.promo_code}")
 
    return order
 
@app.post("/orders")
async def create_order(
    order: OrderCreate = Depends(validate_promo_code)  # Валидация через dependency
):
    # Здесь order уже провалидирован (и схема, и промокод)
    db.add(order)
    return order

Паттерн 5: Multi-Step Validation (валидация в несколько этапов)

from pydantic import BaseModel, field_validator, model_validator
from typing import Any
 
class ComplexForm(BaseModel):
    """Форма с многоэтапной валидацией."""
    email: str
    password: str
    password_confirm: str
    terms_accepted: bool
 
    # Этап 1: Field-level validation (проверка формата)
    @field_validator('email')
    @classmethod
    def validate_email_format(cls, v: str) -> str:
        """Проверка формата email."""
        if '@' not in v or '.' not in v.split('@')[1]:
            raise ValueError('Invalid email format')
        return v.lower()
 
    @field_validator('password')
    @classmethod
    def validate_password_strength(cls, v: str) -> str:
        """Проверка силы пароля."""
        if len(v) < 8:
            raise ValueError('Password too short')
        if not any(c.isdigit() for c in v):
            raise ValueError('Password must contain digit')
        return v
 
    # Этап 2: Model-level validation (проверка связей между полями)
    @model_validator(mode='after')
    def validate_passwords_match(self):
        """Проверка совпадения паролей."""
        if self.password != self.password_confirm:
            raise ValueError('Passwords do not match')
        return self
 
    @model_validator(mode='after')
    def validate_terms_accepted(self):
        """Проверка принятия условий."""
        if not self.terms_accepted:
            raise ValueError('You must accept terms and conditions')
        return self
 
# Этап 3: Business logic validation (в endpoint)
@app.post("/register")
async def register(form: ComplexForm):
    # Проверяем уникальность email (I/O операция)
    existing_user = await db.users.find_one({"email": form.email})
    if existing_user:
        raise HTTPException(400, "Email already registered")
 
    # Создаём пользователя
    user = await create_user(form)
    return user

Паттерн 6: Schema Evolution (поддержка старых версий)

from pydantic import BaseModel, model_validator
from typing import Literal
 
class UserV1(BaseModel):
    """Версия 1: одно поле для имени."""
    version: Literal["v1"] = "v1"
    name: str
    email: str
 
class UserV2(BaseModel):
    """Версия 2: раздельные first_name и last_name."""
    version: Literal["v2"] = "v2"
    first_name: str
    last_name: str
    email: str
 
    @model_validator(mode='before')
    @classmethod
    def migrate_from_v1(cls, data: Any) -> Any:
        """Автоматическая миграция из v1."""
        if isinstance(data, dict):
            # Определяем v1 по наличию поля 'name'
            if 'name' in data and 'first_name' not in data:
                parts = data['name'].split(' ', 1)
                data['first_name'] = parts[0]
                data['last_name'] = parts[1] if len(parts) > 1 else ''
                data.pop('name', None)
                data['version'] = 'v2'
 
        return data
 
# API принимает обе версии
@app.post("/users")
def create_user(user_data: dict):
    # Пробуем v2 (с автомиграцией из v1)
    try:
        user = UserV2(**user_data)
    except ValidationError:
        # Fallback на v1 (если данные совсем не подходят)
        user = UserV1(**user_data)
 
    # Внутренняя логика работает только с v2
    if isinstance(user, UserV1):
        user = convert_v1_to_v2(user)
 
    db.add(user)
    return user

Паттерн 7: Partial Updates (частичное обновление)

from pydantic import BaseModel, Field
from typing import Optional
 
class UserUpdate(BaseModel):
    """Модель для частичного обновления пользователя."""
    username: str | None = None
    email: str | None = None
    bio: str | None = None
 
    @model_validator(mode='after')
    def at_least_one_field(self):
        """Проверка, что хотя бы одно поле передано."""
        if all(v is None for v in [self.username, self.email, self.bio]):
            raise ValueError('At least one field must be provided')
        return self
 
@app.patch("/users/{user_id}")
def update_user(user_id: int, update: UserUpdate):
    # Получаем существующего пользователя
    user = db.query(User).get(user_id)
 
    # Обновляем только переданные поля
    update_data = update.model_dump(exclude_unset=True)  # Только заполненные поля
 
    for field, value in update_data.items():
        setattr(user, field, value)
 
    db.commit()
    return user

Сравнение подходов к валидации

ПодходКогда использоватьПлюсыМинусы
Field constraintsПростые правила (length, pattern)Декларативно, быстроНе подходит для сложной логики
@field_validatorВалидация одного поляКонтроль над парсингомНет доступа к другим полям
@model_validatorПроверка связей между полямиВидит всю модельВыполняется после всех field validators
ValidationInfo contextRuntime данные (locale, user, flags)Динамические правилаНужно передавать context явно
Dependency injectionI/O операции (DB, API calls)Async, testable, отделён от моделиБольше кода
Schema evolutionПоддержка старых версий APIОбратная совместимостьСложность миграции

Сериализация

field_serializer: кастомизация отдельных полей

from pydantic import BaseModel, field_serializer
from datetime import datetime
from decimal import Decimal
from uuid import UUID
 
class Transaction(BaseModel):
    id: UUID
    amount: Decimal
    currency: str
    created_at: datetime
    description: str | None = None
 
    @field_serializer('id')
    def serialize_uuid(self, value: UUID) -> str:
        """UUID → строка для JSON."""
        return str(value)
 
    @field_serializer('amount')
    def serialize_amount(self, value: Decimal, _info) -> str:
        """Decimal → строка с 2 знаками для точности."""
        return f"{value:.2f}"
 
    @field_serializer('created_at')
    def serialize_datetime(self, value: datetime) -> str:
        """Datetime → ISO 8601."""
        return value.isoformat()
 
    @field_serializer('description')
    def serialize_description(self, value: str | None) -> str:
        """None → пустая строка для клиента."""
        return value if value else ""
 
# Результат
tx = Transaction(
    id=UUID("123e4567-e89b-12d3-a456-426614174000"),
    amount=Decimal("100.5"),
    currency="USD",
    created_at=datetime(2025, 12, 4, 10, 30),
    description=None
)
 
print(tx.model_dump())
# {
#     'id': '123e4567-e89b-12d3-a456-426614174000',
#     'amount': '100.50',
#     'currency': 'USD',
#     'created_at': '2025-12-04T10:30:00',
#     'description': ''
# }

model_serializer: полный контроль над моделью

from pydantic import BaseModel, model_serializer
 
class User(BaseModel):
    id: int
    username: str
    email: str
    password_hash: str
    is_active: bool
    roles: list[str]
 
    @model_serializer
    def serialize_model(self):
        """Кастомная сериализация: исключаем password_hash, добавляем computed поля."""
        return {
            'id': self.id,
            'username': self.username,
            'email': self.email,
            # password_hash намеренно пропущен
            'status': 'active' if self.is_active else 'inactive',
            'permissions': self._compute_permissions()
        }
 
    def _compute_permissions(self) -> list[str]:
        """Вычисление permissions на основе roles."""
        permissions_map = {
            'admin': ['read', 'write', 'delete', 'manage_users'],
            'editor': ['read', 'write'],
            'viewer': ['read']
        }
        permissions = set()
        for role in self.roles:
            permissions.update(permissions_map.get(role, []))
        return sorted(permissions)
 
user = User(
    id=1,
    username="alice",
    email="alice@example.com",
    password_hash="$2b$12$KIXqz...",
    is_active=True,
    roles=["admin", "editor"]
)
 
print(user.model_dump())
# {
#     'id': 1,
#     'username': 'alice',
#     'email': 'alice@example.com',
#     'status': 'active',
#     'permissions': ['delete', 'manage_users', 'read', 'write']
# }

Serialization aliases: snake_case ↔ camelCase

from pydantic import BaseModel, Field, ConfigDict
from pydantic.alias_generators import to_camel
 
# Автоматическое преобразование всех полей
class AutoCamelCaseModel(BaseModel):
    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True  # Принимаем и snake_case и camelCase на вход
    )
 
    user_id: int
    first_name: str
    is_active: bool
 
# Использование
user = AutoCamelCaseModel(user_id=1, first_name="Alice", is_active=True)
print(user.model_dump(by_alias=True))
# {'userId': 1, 'firstName': 'Alice', 'isActive': True}
 
# Ручное управление алиасами для сложных кейсов
class ManualAliasModel(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
 
    user_id: int = Field(serialization_alias="userId")
    first_name: str = Field(
        validation_alias="firstName",  # На вход принимаем firstName
        serialization_alias="first_name"  # На выход отдаём first_name
    )
 
# Поддержка legacy API
from pydantic import AliasChoices
 
class LegacyCompatibleModel(BaseModel):
    user_id: int = Field(
        validation_alias=AliasChoices('userId', 'user_id', 'id'),  # Принимаем все варианты
        serialization_alias='userId'  # Отдаём только один
    )
 
# Все эти варианты работают
m1 = LegacyCompatibleModel.model_validate({'userId': 1})
m2 = LegacyCompatibleModel.model_validate({'user_id': 1})
m3 = LegacyCompatibleModel.model_validate({'id': 1})
 
# Но сериализация всегда одинаковая
print(m1.model_dump(by_alias=True))  # {'userId': 1}

Валидация как забор

Проблема: Mass Assignment Attack

# ❌ Опасно: клиент может установить любые поля
class User(BaseModel):
    model_config = ConfigDict(extra='allow')  # Разрешаем любые поля!
 
    username: str
    email: str
    # is_admin: bool, внутреннее поле, но не объявлено
 
@app.post("/users")
def create_user(user_data: dict):
    user = User(**user_data)  # Что если клиент передаст is_admin=True?
    db.add(user)
    return user
 
# Атака:
# POST /users
# {
#   "username": "hacker",
#   "email": "hack@evil.com",
#   "is_admin": true  ← попытка повышения привилегий
# }

Решение:

# ✅ Безопасно: явно разделяем input и internal модели
class UserCreate(BaseModel):
    """Модель для создания пользователя (только публичные поля)."""
    model_config = ConfigDict(extra='forbid')  # Запрещаем лишние поля
 
    username: str = Field(min_length=3, max_length=20)
    email: str
    password: str = Field(min_length=8)
 
class User(BaseModel):
    """Внутренняя модель (с приватными полями)."""
    id: int
    username: str
    email: str
    password_hash: str  # Хранится только hash
    is_admin: bool = False  # Устанавливается только сервером
    created_at: datetime
 
@app.post("/users")
def create_user(user_create: UserCreate):
    # Создаём internal модель с контролируемыми полями
    user = User(
        id=generate_id(),
        username=user_create.username,
        email=user_create.email,
        password_hash=hash_password(user_create.password),
        is_admin=False,  # Всегда False для новых пользователей
        created_at=datetime.utcnow()
    )
    db.add(user)
    return user

SQL Injection через валидацию

# ❌ Опасно: валидация не спасёт от SQL injection в raw queries
class ProductSearch(BaseModel):
    query: str
 
@app.get("/products/search")
def search_products(search: ProductSearch):
    # НИКОГДА ТАК НЕ ДЕЛАЙТЕ!
    sql = f"SELECT * FROM products WHERE name LIKE '%{search.query}%'"
    results = db.execute(sql).fetchall()
    return results
 
# Атака:
# GET /products/search?query='; DROP TABLE products; --

Решение:

# ✅ Безопасно: валидация + parameterized queries
class ProductSearch(BaseModel):
    query: str = Field(
        min_length=1,
        max_length=100,
        pattern=r'^[a-zA-Z0-9\s\-]+$'  # Только буквы, цифры, пробелы, тире
    )
 
@app.get("/products/search")
def search_products(search: ProductSearch):
    # Используем parameterized query (защищено ORM)
    results = db.query(Product).filter(
        Product.name.ilike(f'%{search.query}%')
    ).all()
 
    return results

XSS prevention через валидацию

from pydantic import field_validator
import html
 
class CommentCreate(BaseModel):
    text: str = Field(max_length=1000)
    author_name: str = Field(max_length=50)
 
    @field_validator('text', 'author_name')
    @classmethod
    def sanitize_html(cls, v: str) -> str:
        """Экранируем HTML для защиты от XSS."""
        if isinstance(v, str):
            # Убираем HTML теги
            v = html.escape(v)
 
            # Опционально: используем bleach для более умной очистки
            # import bleach
            # v = bleach.clean(v, tags=[], strip=True)
 
        return v
 
# Пример:
comment = CommentCreate(
    text="<script>alert('XSS')</script>Nice post!",
    author_name="Alice<img src=x onerror=alert(1)>"
)
 
print(comment.text)  # &lt;script&gt;alert('XSS')&lt;/script&gt;Nice post!
print(comment.author_name)  # Alice&lt;img src=x onerror=alert(1)&gt;

Path Traversal prevention

import os
from pathlib import Path
 
class FileRequest(BaseModel):
    filename: str
 
    @field_validator('filename')
    @classmethod
    def validate_safe_path(cls, v: str) -> str:
        """Проверка на path traversal атаки."""
        # Запрещаем ../ и абсолютные пути
        if '..' in v or v.startswith('/') or v.startswith('\\'):
            raise ValueError('Invalid filename: path traversal attempt')
 
        # Проверяем, что файл в разрешённой директории
        base_dir = Path('/var/app/uploads')
        file_path = (base_dir / v).resolve()
 
        if not str(file_path).startswith(str(base_dir)):
            raise ValueError('Invalid filename: outside allowed directory')
 
        return v
 
# Блокирует атаки:
# filename = "../../etc/passwd" → ValidationError
# filename = "/etc/passwd" → ValidationError
# filename = "uploads/safe.txt" → OK

PII (Personally Identifiable Information) защита

from pydantic import field_serializer
 
class UserProfile(BaseModel):
    id: int
    email: str
    phone: str
    ssn: str  # Social Security Number
 
    @field_serializer('email')
    def mask_email(self, value: str) -> str:
        """Маскируем email в логах."""
        if '@' in value:
            local, domain = value.split('@')
            return f"{local[:2]}***@{domain}"
        return "***"
 
    @field_serializer('phone')
    def mask_phone(self, value: str) -> str:
        """Маскируем телефон в логах."""
        return f"***-***-{value[-4:]}" if len(value) >= 4 else "***"
 
    @field_serializer('ssn')
    def mask_ssn(self, value: str) -> str:
        """Полностью скрываем SSN."""
        return "***-**-****"
 
# Для логирования
user = UserProfile(id=1, email="alice@example.com", phone="+79001234567", ssn="123-45-6789")
 
# В логи идёт:
print(user.model_dump())
# {
#   "id": 1,
#   "email": "al***@example.com",
#   "phone": "***-***-4567",
#   "ssn": "***-**-****"
# }

Rate limiting через валидацию

from functools import lru_cache
from time import time
 
class RequestWithRateLimit(BaseModel):
    user_id: int
    action: str
 
    @model_validator(mode='after')
    def check_rate_limit(self):
        """Простейший rate limit на уровне валидации."""
        key = f"{self.user_id}:{self.action}"
 
        # Проверяем rate limit (хранится в кэше)
        if is_rate_limited(key):
            raise ValueError(
                f"Rate limit exceeded for action '{self.action}'. "
                "Try again later."
            )
 
        # Записываем timestamp запроса
        record_request(key)
 
        return self
 
# Простая реализация rate limit (в production используйте Redis)
_rate_limit_cache = {}
 
def is_rate_limited(key: str, max_requests: int = 10, window_seconds: int = 60) -> bool:
    """Проверка rate limit."""
    now = time()
 
    if key not in _rate_limit_cache:
        _rate_limit_cache[key] = []
 
    # Очищаем старые запросы
    _rate_limit_cache[key] = [
        ts for ts in _rate_limit_cache[key]
        if now - ts < window_seconds
    ]
 
    return len(_rate_limit_cache[key]) >= max_requests
 
def record_request(key: str):
    """Записываем timestamp запроса."""
    now = time()
    if key not in _rate_limit_cache:
        _rate_limit_cache[key] = []
    _rate_limit_cache[key].append(now)

Security Checklist для Production

# Чеклист безопасности для моделей Pydantic
 
class SecureProductionModel(BaseModel):
    """Базовая модель с security best practices."""
 
    model_config = ConfigDict(
        # 1. Запрещаем лишние поля (защита от mass assignment)
        extra='forbid',
 
        # 2. Строгая типизация для критичных данных
        strict=True,
 
        # 3. Валидация defaults (если генерируются динамически)
        validate_default=True,
 
        # 4. Запрещаем произвольные типы (только известные)
        arbitrary_types_allowed=False,
    )
 
    # 5. Всегда используйте Field constraints
    username: str = Field(
        min_length=3,
        max_length=20,
        pattern=r'^[a-zA-Z0-9_]+$'  # Только безопасные символы
    )
 
    # 6. Sensitive data, только для internal моделей
    # Никогда не возвращайте пароли/токены в API responses
 
    # 7. Маскируйте PII в serialization
    @field_serializer('email')
    def mask_email_for_logs(self, value: str) -> str:
        return mask_email(value)
 
    # 8. Валидируйте бизнес-правила
    @model_validator(mode='after')
    def validate_business_rules(self):
        # Проверка прав, rate limits, etc.
        return self

Шпаргалка ConfigDict

ПараметрИспользованиеПримерTrade-off
strict=TrueФинансы, конфиги, критичные данныеamount: Decimal = Field(strict=True)-4% время
extra='forbid'Публичные API (защита от mass assignment)model_config = ConfigDict(extra='forbid')+2% overhead
validate_assignment=TrueLong-lived мутабельные объектыCache, sessions, state machines+68% на присваивание
revalidate_instances='never'High-throughput с вложенными моделямиInternal DTOs, trusted data-16% время
from_attributes=TrueORM интеграцияUserSchema.model_validate(db_user)Нет overhead
frozen=TrueImmutable данныеEvents, value objectsЛучше чем validate_assignment
use_enum_values=TrueAPI responsesJSON сериализация EnumНет overhead

Рецепты для типичных сценариев

High-Throughput API (100k+ rps):

model_config = ConfigDict(
    strict=True,                    # -4%
    extra='forbid',                 # +2%
    revalidate_instances='never',   # -16%
)
# Итого: ~18% быстрее baseline

User Input API (безопасность > скорость):

model_config = ConfigDict(
    strict=False,        # Flexibility
    extra='forbid',      # Security
    validate_default=True,
)

Internal DTO (trusted data):

model_config = ConfigDict(
    strict=False,
    extra='ignore',
    revalidate_instances='never',
)

Валидация: 4 уровня

УровеньКогда использоватьПример
Field constraintsПростые правила (length, pattern, range)username: str = Field(min_length=3)
@field_validatorВалидация одного поля, парсинг@field_validator('phone') def parse_phone(v)
@model_validatorCross-field validationПроверка end_date > start_date
Runtime contextДинамические правила (permissions, locale)ValidationInfo.context

Security Checklist

  • extra='forbid' для всех API endpoints
  • strict=True для финансов и платежей
  • ✅ Никогда не делайте I/O в валидаторах (используйте dependencies)
  • ✅ Маскируйте PII в @field_serializer для логов
  • ✅ Валидируйте на границе системы, используйте model_construct() внутри

Цена, флаги и утечка чинились не новым фреймворком. Их чинил явный ConfigDict и валидация на границе. Дальше скорость: часть 3. Контракты между сервисами: часть 4.