В первой части 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().
Читать можно с любого раздела. Код не трогал.
- Часть 1. Миграция
- Часть 2. ConfigDict и валидация (здесь)
- Часть 3. Скорость
- Часть 4. Сервисы
- Часть 5. Async, GraphQL, брокеры
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 | Точность критична |
| Конфиги | True | Feature flags инцидент (см. часть 1) |
| API request/response | False | Удобство клиентов |
| Internal DTOs | False | Гибкость |
| Database models | False | ORM может возвращать разные типы |
Параметр 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 | Тип | Описание | Когда использовать |
|---|---|---|---|---|
strict | False | bool | Строгая типизация без приведения | Финансы, конфиги, критичные данные |
extra | 'ignore' | str | Поведение с лишними полями | 'forbid' для API, 'allow' для legacy |
validate_default | False | bool | Валидировать default значения | Если defaults динамические или могут быть невалидными |
validate_assignment | False | bool | Валидация при model.field = value | Long-lived мутабельные объекты |
from_attributes | False | bool | Создание из объектов (ORM) | SQLAlchemy, Django ORM |
populate_by_name | False | bool | Принимать и alias и реальное имя | Обратная совместимость API |
use_enum_values | False | bool | Сериализовать Enum как .value | API responses (JSON не поддерживает Enum) |
frozen | False | bool | Immutable модель | DTO, events, value objects |
arbitrary_types_allowed | False | bool | Разрешить нестандартные типы | Интеграция с внешними библиотеками (осторожно!) |
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) # TrueRoot 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.2GBRoot 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)Проблемы дефолтного вывода:
- Технические детали (
type=value_error.email), не понятны пользователям - Нет локализации
- Нет mapping на конкретные поля формы (для фронтенда)
- Не логируется для аналитики
Паттерн 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 context | Runtime данные (locale, user, flags) | Динамические правила | Нужно передавать context явно |
| Dependency injection | I/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 userSQL 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 resultsXSS 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) # <script>alert('XSS')</script>Nice post!
print(comment.author_name) # Alice<img src=x onerror=alert(1)>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" → OKPII (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=True | Long-lived мутабельные объекты | Cache, sessions, state machines | +68% на присваивание |
revalidate_instances='never' | High-throughput с вложенными моделями | Internal DTOs, trusted data | -16% время |
from_attributes=True | ORM интеграция | UserSchema.model_validate(db_user) | Нет overhead |
frozen=True | Immutable данные | Events, value objects | Лучше чем validate_assignment |
use_enum_values=True | API responses | JSON сериализация Enum | Нет overhead |
Рецепты для типичных сценариев
High-Throughput API (100k+ rps):
model_config = ConfigDict(
strict=True, # -4%
extra='forbid', # +2%
revalidate_instances='never', # -16%
)
# Итого: ~18% быстрее baselineUser 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_validator | Cross-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.
