Как устроена валидация данных: от Pydantic до ошибок для пользователя
Покажем, как формируется набор ошибок, как корректно возвращать их клиенту и как сделать сообщения понятными, а не “шумными”.
Содержание
Как устроена валидация данных: от Pydantic до ошибок для пользователя
Валидация данных — это не только про «проверить типы». В нормальном приложении она превращается в управляемую подсистему: формируется понятный набор ошибок, эти ошибки безопасно и корректно возвращаются клиенту, а сообщения превращаются из “шумного технического лога” в удобный интерфейс для пользователя (и одновременно — в достаточно подробную диагностику для разработчика).
В экосистеме Python одну из самых удобных основ под валидацию даёт Pydantic (в том числе через интеграции с FastAPI). Но даже при использовании готовых библиотек многие команды сталкиваются с одними и теми же проблемами:
- ошибки приходят в виде “массивов” без ясного смысла для человека;
- клиенту возвращаются слишком детали/внутренняя структура модели;
- сообщения дублируются, путаются по полям или не соответствуют ожидаемому формату фронтенда;
- часть ошибок формируется не в том месте (например, валидация тела запроса — отдельно, а бизнес-валидация — отдельно), и в итоге клиент получает несогласованный интерфейс.
Ниже разберём, как на самом деле устроен путь ошибки: от стадии формирования “карты” ошибок в Pydantic до финального ответа API. А ещё — как сделать так, чтобы клиент получил человеческие сообщения, не теряя при этом точности.
1. От Pydantic до уровня API: где именно рождаются ошибки
1.1. Pydantic как фабрика структуры ошибок
Pydantic валидирует входные данные (обычно dict или объект), пытаясь сопоставить их структуре модели. Когда что-то не сходится — формируется список проблем (обычно это errors() / model_validate-механизмы внутри Pydantic).
С точки зрения приложения это важно: валидатор обычно не “возвращает сообщение”, а создаёт структурированную информацию:
- какое поле/путь не прошло валидацию,
- какой тип ошибки,
- параметры (например, ожидаемые длины, паттерн, значение и т. п.),
- итоговое человекочитаемое описание.
На практике, если вы поймёте эту структуру, вы сможете:
- аккуратно преобразовывать её под формат вашего API;
- локализовать/нормализовать сообщения;
- выводить клиенту ровно то, что нужно, без лишней “технической пены”.
1.2. Ошибка как исключение: смысл ValidationError
Когда валидация не проходит, Pydantic возбуждает исключение (типично ValidationError). В FastAPI оно перехватывается интеграционным слоем и превращается в HTTP-ответ (как правило, 422 Unprocessable Entity).
И ключевой момент: в этом этапе ошибки уже имеют достаточно точную структуру — осталось правильно её интерпретировать и, при необходимости, перепаковать.
1.3. Разделение типов проблем: технические и бизнес-ошибки
Важно различать:
- ошибки валидации схемы (не проходит тип/формат/ограничения полей);
- ошибки бизнес-логики (например, “email уже зарегистрирован”, “дата окончания раньше даты начала”);
- ошибки доступа/авторизации (тут другая подсистема);
- ошибки инфраструктуры (таймауты, падения внешних сервисов).
Если смешивать их в один массив без контекста — клиент быстро теряет смысл происходящего. Поэтому даже если источник ошибок — Pydantic, вам нужно продумать общий контракт ошибок для API.
2. Как формируется набор ошибок: anatomy ошибок Pydantic
2.1. Типичная структура данных ошибки
В Pydantic (и в интеграциях с FastAPI) вы почти всегда встретите концепцию “ошибка на пути”. Часто путь оформляется как список сегментов (например, ["items", 0, "price"]).
Логика такая:
loc— локация (путь до проблемного места);msg— сообщение;type— класс/категория ошибки (например,value_error,type_error,missing, и т. п.);- дополнительные параметры (в зависимости от категории).
Внутри ValidationError это можно увидеть так:
from pydantic import BaseModel, ValidationError, Field
from typing import List
class Item(BaseModel):
name: str
price: float = Field(gt=0)
class Order(BaseModel):
items: List[Item]
quantity: int = Field(ge=1)
try:
Order.model_validate({"items": [{"name": "Pen", "price": -10}], "quantity": 0})
except ValidationError as e:
print(e.errors())
Результат (конкретный формат зависит от версии Pydantic), как правило будет списком словарей примерно такого вида:
- ошибка в
items.0.price(должно быть > 0), - ошибка в
quantity(должно быть ≥ 1).
2.2. Почему “loc” — это и сила, и источник проблем
loc позволяет точно понять, где именно не сошлось. Но у клиентских приложений часто ожидается простая модель: “ошибка в поле price” или “ошибка в поле items[0].price”.
Если вы напрямую отдаёте loc как “сырые сегменты”, фронтенд может не знать, как это распаковать. Например:
loc = ("body", "items", 0, "price")— из FastAPI это “путь в запросе”;- а клиент ожидал бы
field = "items[0].price"или даже более “плоское” сообщение.
Отсюда правило: внутренний путь не равен контракту API.
3. Контракт ошибок: как вернуть их клиенту без лишнего шума
3.1. Важный принцип: отделяйте “машинную” и “человеческую” части
Хороший ответ об ошибках обычно включает:
- машинное описание (код/тип/параметры), чтобы клиент мог локализовать и сопоставлять ошибки;
- человеческое сообщение (или набор сообщений), чтобы пользователь сразу понял, что исправить;
- идентификацию поля (или элемента формы).
В JSON-практике популярны форматы вида:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Ошибка валидации данных.",
"details": [
{
"field": "quantity",
"message": "Должно быть не меньше 1",
"type": "greater_than_equal",
"limit": 1
}
]
}
}
Ваша задача — преобразовать структуру Pydantic/ FastAPI в такой вид.
3.2. Где именно перехватывать: чтобы не ломать поведение FastAPI
В FastAPI есть стандартные обработчики ошибок валидации. Но если вы хотите изменить формат для клиента — обычно нужно написать свой обработчик для RequestValidationError.
В базовом случае FastAPI уже отдаёт 422 с деталями. Но “детали” часто достаточно техничны: поле называется “loc”, сообщение — “msg”, плюс там присутствует лишняя информация о “body”.
Если вам нужен спокойный клиентский контракт — добавляйте обработчик и перепаковывайте.
4. Пример: пользовательские ошибки для валидации в FastAPI
Ниже пример “как сделать лучше”: мы преобразуем ошибки в предсказуемый формат, превращая loc в удобные field и нормализуя сообщения.
4.1. Модель и ручные ограничения
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from typing import Any, List, Dict, Optional
app = FastAPI()
class ItemIn(BaseModel):
name: str = Field(min_length=2)
price: float = Field(gt=0)
class CreateOrderIn(BaseModel):
items: List[ItemIn]
quantity: int = Field(ge=1)
4.2. Функции нормализации: делаем loc дружелюбным
Рассмотрим типичный loc, который может выглядеть как ("body", "items", 0, "price"). Для клиента лучше представить это как items[0].price (или как минимум items.0.price).
def loc_to_field(loc: List[Any]) -> str:
"""
Превращаем loc из FastAPI/Pydantic в строку вида items[0].price.
"""
# Уберём служебный префикс 'body' если он есть
parts = [p for p in loc if p != "body"]
result = ""
for p in parts:
if isinstance(p, int):
result += f"[{p}]"
else:
if not result:
result = str(p)
else:
result += f".{p}"
return result or "unknown"
4.3. Нормализация сообщений: убираем “шум”
Сообщение Pydantic достаточно информативно, но часто слишком буквальное. Можно применить простую нормализацию: если msg уже понятное — оставляем, если нет — дополняем.
Важный момент: не пытайтесь “сгенерировать идеальный текст” для каждого типа ошибки. Лучше сделать 80% пользы: убрать лишние части и привести к единому стилю.
def normalize_message(msg: str, err_type: str, ctx: Optional[Dict[str, Any]] = None) -> str:
# Примитивные правила; в реальном проекте лучше расширить под нужды/локаль.
if "ensure this value is greater than or equal to" in msg:
# Pydantic иногда формулирует очень длинно — оставим как есть или укоротим.
return msg.replace("ensure this value is greater than or equal to", "Должно быть не меньше")
if "ensure this value is greater than" in msg:
return msg.replace("ensure this value is greater than", "Должно быть больше")
if "field required" in msg.lower():
return "Поле обязательно"
return msg
4.4. Обработчик ошибок и формирование контракта
Перехватываем RequestValidationError (это ошибки схемы для входящих запросов).
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
raw_errors = exc.errors()
details = []
for e in raw_errors:
loc = e.get("loc", [])
field = loc_to_field(loc)
msg = e.get("msg", "Validation error")
err_type = e.get("type", "validation_error")
ctx = e.get("ctx") # иногда присутствует
details.append({
"field": field,
"message": normalize_message(msg, err_type, ctx),
"type": err_type,
# ctx лучше отдавать только если вы действительно используете это на фронтенде:
# "limit": ctx.get("limit") ...
})
return JSONResponse(
status_code=422,
content={
"error": {
"code": "VALIDATION_ERROR",
"message": "Ошибка валидации данных.",
"details": details
}
}
)
4.5. Проверка: что увидит клиент
Если отправить, например:
quantity = 0,items[0].price = -10,items[0].name = "P"(слишком короткое),
клиент получит:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Ошибка валидации данных.",
"details": [
{ "field": "quantity", "message": "Должно быть не меньше 1", "type": "greater_than_equal" },
{ "field": "items[0].name", "message": "String should have at least 2 characters", "type": "string_too_short" },
{ "field": "items[0].price", "message": "ensure this value is greater than 0", "type": "greater_than" }
]
}
}
Здесь уже есть ключевое преимущество: клиентская форма может “подсветить” конкретные поля. А разработчик — увидеть понятную карту проблем, не анализируя сырые loc.
5. Самые частые ошибки при работе с валидацией и сообщениями
5.1. Отдавать “loc” как есть, без нормализации
loc — внутреннее представление структуры запроса. Оно меняется от контекста: иногда это ("body", "field"), иногда есть “path” или “query”. Клиенту будет больно.
Правило: преобразуйте loc к вашему стабильному формату field.
5.2. Смешивать в одном ответе ошибки валидации и бизнес-ошибки, не различая тип
Если “кол-во уже использовано” и “quantity меньше 1” будут лежать в одном массиве с одинаковыми структурами, фронтенд будет вынужден гадать.
Правило: бизнес-ошибкам присваивайте свои code/type или отдельный блок. Валидация схемы — отдельно.
5.3. Игнорировать локализацию
Сообщения Pydantic написаны на английском (по умолчанию). Если у вас русскоязычные пользователи — вам потребуется слой переводов или переформулировки.
Не обязательно переводить всё вручную: можно начать с нормализации “самых частых” типов ошибок (missing, greater_than, string_too_short, pattern mismatch). Но лучше сразу заложить механизм локализации: словарь “type → шаблон”.
5.4. Не думать про стабильность формата ошибок
Если вы один раз “подрихтовали” структуру деталей, но в следующий релиз добавили новые ключи или изменили формат field, фронтенд ломается. Стабильный контракт — часть качества продукта.
Практика: закрепляйте структуру в тестах (хотя бы интеграционных) и документируйте.
5.5. Делать “слишком умные” сообщения и терять причинность
Иногда авторы пытаются сделать “красивый” текст, но в итоге теряют параметры валидации: например, клиенту нужно не только понять “слишком короткое”, но и “минимум 2 символа”. Поэтому лучше сохранять и type, и опциональные параметры (например, limit, min_length, pattern), если это используется в UI.
6. Как сделать сообщения понятными: практические стратегии
6.1. Уровень “для пользователя”: кратко, предсказуемо, без технических формулировок
Пользователь должен видеть:
- что именно не так (поле),
- как исправить (минимум/максимум/формат),
- возможно, один вариант действия (например, “введите телефон в формате …”).
6.2. Уровень “для разработчика”: детали можно оставить в логах или в расширенном поле
Хороший компромисс:
- в ответе клиенту — минимум, который реально используется;
- в расширенном поле (или в логах) —
type, rawctx, исходное сообщение Pydantic.
Например:
{
"field": "items[0].price",
"message": "Цена должна быть больше 0",
"type": "greater_than",
"debug": { "raw_msg": "ensure this value is greater than 0" }
}
Если вы не используете debug на фронтенде — уберите его или защищайте доступом.
6.3. Единый стиль: одна терминология для всех ручных валидаторов
Помимо схемы, у вас будут проверки в сервисном слое. Чтобы пользователь не видел “два разных подхода к одному и тому же”, лучше использовать единый интерфейс ошибок: тот же field/message/type.
Например, если бизнес-валидация говорит “количество не может быть меньше 1”, она должна выдавать ошибку в той же форме, что и Pydantic-ограничение ge=1.
7. Типовые расширения: кастомные сообщения и карта ошибок для фронтенда
7.1. Локализация через type вместо парсинга текста
Парсить msg строками хрупко: формулировки могут отличаться между версиями и типами валидаторов. Надёжнее:
- брать
type; - маппить на шаблон на нужном языке.
Пример минимальной мапы:
ERROR_TEMPLATES_RU = {
"missing": "Поле обязательно",
"string_too_short": "Слишком короткое значение (минимум {min_length} символов)",
"greater_than": "Значение должно быть больше {limit}",
"greater_than_equal": "Значение должно быть не меньше {limit}",
}
def render_ru_message(err_type: str, msg: str, ctx: dict | None) -> str:
template = ERROR_TEMPLATES_RU.get(err_type)
if not template or not ctx:
return msg
return template.format(**ctx)
А дальше используйте это вместо грубой заменой текста.
7.2. Тестирование контракта ошибок
Интеграционные тесты на ошибки валидации часто проще, чем кажется. Вы проверяете, что:
- статус 422,
- структура
error.code, - что в
detailsесть нужныеfield, - что сообщения содержат ожидаемые фрагменты.
Это предотвращает “тихие” поломки для фронтенда.
8. Вывод: валидация — это UX, а не просто “422”
Валидация данных в API —
Комментарии
Пока нет комментариев