Опирается на правила: R-VLD-MSG-1R-VLD-MSG-3 и R-VLD-MSG-X1R-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_validatorR-VLD-MSG-X3Custom Annotated-тип с одним валидатором
Стандартные английские сообщения Pydantic без переопределенияR-VLD-MSG-1exception_handler с маппингом на русские тексты
Hardcoded числа в тексте без контекстаR-VLD-MSG-2f-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.