Опирается на правила:
R-VLD-MSG-1…R-VLD-MSG-3иR-VLD-MSG-X1…R-VLD-MSG-X3из Validation Style Guide → раздел 8. Сообщения и i18n.
Важно знать
message— на русском. Это текст, который попадёт вviolations[].messageи дойдёт до пользователя на UI.- Для пользователя, не для разработчика. «Сумма должна быть положительной», не «value must be greater than 0».
- Интерполяция — формируйте текст с реальными значениями в
ValueError(f"..."). Pydantic не имеет Java-style{value}placeholder; строку строим явно.- Стандартные сообщения Pydantic — на английском. Без переопределения они попадут в UI. Переопределяем через
Field(...)параметры или кастомныйexception_handler.- Дублирование text на каждом поле без причины — лишний шум, синхронизировать трудно.
ValueError в @field_validator или @model_validator — единственный текст, который при невалидном вводе доходит до конечного пользователя через violations[].message в ProblemDetails. Это product copy, не «лог для разработчика». Раскрытие раздела 8 гайда.
Текст на русском, для пользователя
R-VLD-MSG-1: текст ValueError — на языке UI (в проекте — русский).
from pydantic import BaseModel, EmailStr, Field, field_validator
from decimal import Decimal
class CreateProductRequest(BaseModel):
name: str = Field(min_length=1, max_length=200)
price: Decimal = Field(gt=0, decimal_places=2)
email: EmailStr
@field_validator("name")
@classmethod
def _name_not_whitespace(cls, v: str) -> str:
if v.strip() != v:
raise ValueError("Название не должно начинаться или заканчиваться пробелами")
return v
Что попадёт в ответ при пустом name:
{
"type": "/errors/validation-error",
"status": 400,
"violations": [
{"field": "name", "message": "Название не должно начинаться или заканчиваться пробелами"}
]
}
R-VLD-MSG-X1: английский в сообщении — антипаттерн.
# ПЛОХО
raise ValueError("Name must not start or end with whitespace")
# ХОРОШО
raise ValueError("Название не должно начинаться или заканчиваться пробелами")
Текст попадёт в UI как есть. Перевод на стороне фронта — техдолг, который постоянно отстаёт от backend.
Стандартные сообщения Pydantic — переопределяем
Pydantic v2 генерирует сообщения на английском: "Field required", "String should have at most 100 characters", "Value error, ...". Без переопределения они попадут в violations.
Два способа переопределить:
Способ 1 — через json_schema_extra и alias в Field (для простых случаев):
Pydantic v2 пока не имеет встроенного механизма замены стандартных сообщений constraint-уровня. Наиболее надёжный путь — маппинг в exception_handler:
# backend/api/exception_handlers.py
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
ERROR_MESSAGES = {
"missing": "Поле обязательно",
"string_too_short": "Строка слишком короткая",
"string_too_long": "Строка слишком длинная",
"greater_than": "Значение должно быть больше {gt}",
"greater_than_equal": "Значение должно быть не меньше {ge}",
"less_than_equal": "Значение должно быть не больше {le}",
"decimal_max_digits": "Слишком много цифр в числе",
"value_error": None,
}
def _localize_error(err: dict) -> str:
err_type = err.get("type", "")
ctx = err.get("ctx", {})
if err_type == "value_error":
return err.get("msg", "Некорректное значение").removeprefix("Value error, ")
template = ERROR_MESSAGES.get(err_type)
if template:
return template.format(**ctx)
return "Некорректное значение"
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
violations = [
{
"field": ".".join(str(loc) for loc in err["loc"][1:]),
"message": _localize_error(err),
}
for err in exc.errors()
]
return JSONResponse(
status_code=400,
content={
"type": "/errors/validation-error",
"title": "Ошибка валидации",
"status": 400,
"violations": violations,
},
)
# main.py
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
app = FastAPI()
app.add_exception_handler(RequestValidationError, validation_exception_handler)
Способ 2 — через @field_validator вместо Field(...) для кастомного текста:
class CreateOrderRequest(BaseModel):
quantity: int
@field_validator("quantity")
@classmethod
def _quantity_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("Количество должно быть положительным")
if v > 9999:
raise ValueError(f"Количество не может быть больше 9999, получено {v}")
return v
Интерполяция значений
R-VLD-MSG-2: динамические значения — в текст сообщения через f-string.
from pydantic import BaseModel, Field, field_validator
class OrderItemRequest(BaseModel):
quantity: int = Field(ge=1)
sku: str = Field(min_length=1, max_length=64)
@field_validator("quantity")
@classmethod
def _quantity_range(cls, v: int) -> int:
max_qty = 9999
if v > max_qty:
raise ValueError(f"Количество не может быть больше {max_qty}, получено: {v}")
return v
Что попадёт в ответ при quantity=10000:
{"field": "quantity", "message": "Количество не может быть больше 9999, получено: 10000"}
Пользователь видит реальное значение и лимит, не абстрактное «invalid value».
Сообщение в custom Annotated-типе
R-VLD-MSG-3: для переиспользуемого типа сообщение пишется один раз в функции-валидаторе.
# backend/validation/types.py
def _russian_phone(v: str) -> str:
if not _RU_PHONE.match(v):
raise ValueError("Номер должен быть в формате +7XXXXXXXXXX")
return v
RussianPhone = Annotated[str, AfterValidator(_russian_phone)]
Использование:
class CreateCustomerRequest(BaseModel):
phone: RussianPhone
alt_phone: RussianPhone | None = None
Оба поля используют одно сообщение из _russian_phone. Если сообщение нужно изменить — меняем в одном месте.
R-VLD-MSG-X3: дублирование вручную — антипаттерн:
# ПЛОХО
class CreateCustomerRequest(BaseModel):
phone: Annotated[str, AfterValidator(lambda v: v if _RU_PHONE.match(v)
else (_ for _ in ()).throw(ValueError("Номер должен быть в формате +7XXXXXXXXXX")))]
alt_phone: Annotated[str, AfterValidator(lambda v: v if _RU_PHONE.match(v)
else (_ for _ in ()).throw(ValueError("Номер должен быть в формате +7XXXXXXXXXX")))]
Технические термины — не для пользователя
R-VLD-MSG-X2: пользователь не знает, что такое value, field, str, Decimal.
# ПЛОХО
raise ValueError("str value is too long")
raise ValueError("value must be greater than 0")
raise ValueError("Field amount: invalid Decimal")
# ХОРОШО
raise ValueError("Название не должно превышать 200 символов")
raise ValueError("Сумма должна быть положительной")
raise ValueError("Некорректная сумма: введите число с двумя знаками после запятой")
Тон сообщения — спокойный, описывающий правило:
- «Сумма должна быть положительной» — описывает правило.
- «Вы ввели неправильную сумму» — обвиняет пользователя.
- «Field amount must be positive» — технический.
i18n через словарь по типу ошибки
Если сервис мультиязычный, централизуем переводы в словаре в exception_handler (см. выше). Для русскоязычного сервиса — пишем текст напрямую в ValueError, bundle-подход — избыточен.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Английский в ValueError ("value must be positive") | R-VLD-MSG-X1 | Русский: «Значение должно быть положительным» |
Технические термины (field, str, Decimal, value) | R-VLD-MSG-X2 | Пользовательский язык: «сумма», «название» |
Дублирование одного текста в нескольких @field_validator | R-VLD-MSG-X3 | Custom Annotated-тип с одним валидатором |
| Стандартные английские сообщения Pydantic без переопределения | R-VLD-MSG-1 | exception_handler с маппингом на русские тексты |
| Hardcoded числа в тексте без контекста | R-VLD-MSG-2 | f-string с реальными значениями: f"не более {max_qty}" |
Куда дальше
- Validation → раздел 8. Сообщения и i18n — нормативные формулировки
R-VLD-MSG-*. - python/custom-constraints — где задаётся сообщение для reusable типа.
- python/standard-constraints — какие placeholder-ы доступны в
Field(...). - python/where-to-validate — как
RequestValidationErrorпопадает в ProblemDetails.