Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🌐
Клиент-сервер
Опубликовано:
19.10.2026
•
Обновлено:
19.10.2026

Валидация в FastAPI и Pydantic: вложенные модели, strict-режим и человекопонятные ошибки 422

Максим Быков

При проектировании надежного REST API проверка входящих данных — первая линия защиты сервиса. По умолчанию связка FastAPI и Pydantic v2 берет на себя парсинг и валидацию JSON, возвращая HTTP-статус 422 Unprocessable Entity при любых несоответствиях схеме. Однако в реальных проектах со сложными вложенными объектами, списками и строгими бизнес-правилами стандартного поведения часто недостаточно.

Неявное приведение типов (type coercion) может пропускать строки вместо чисел, кортежи путей loc в стандартном ответе сложны для парсинга на фронтенде, а системные сообщения Pydantic не всегда информативны для конечного пользователя. Разберем, как построить строгий, предсказуемый и тестируемый контракт валидации с помощью инструментов Pydantic v2 и FastAPI.


1. Проблема дефолтной валидации: почему стандартного 422 бывает недостаточно

Структура стандартного ответа RequestValidationError

Когда входящий JSON не проходит валидацию, FastAPI перехватывает исключение pydantic.ValidationError, преобразует его в fastapi.exceptions.RequestValidationError и отдает ответ со статусом 422. Типичное тело ответа выглядит так:

{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "items", 0, "sku"],
      "msg": "Field required",
      "input": {}
    }
  ]
}

Для простых плоских структур этого хватает. Но когда клиент отправляет массив из десятков объектов со вложенными адресами и характеристиками, клиентским разработчикам приходится вручную парсить массив loc, разбираться, относится ли индекс к массиву или объекту, и сопоставлять технические сообщения Pydantic с полями пользовательских форм.

Неявное приведение типов (Coercion) и скрытые баги

По умолчанию Pydantic работает в гибком режиме (lax mode). Это означает, что он пытается привести входящие данные к целевому типу, если это технически возможно:

  • Строка "123" превратится в целое число 123, если поле объявлено как int.
  • Строка "true", "yes", "1" или число 1 превратятся в True для поля bool.
  • Число с плавающей точкой 12.0 превратится в 12 для поля int.

В ряде сценариев такое поведение приводит к логическим ошибкам. Например, строковый идентификатор заказа "0123", попав в поле int, станет числом 123 с потерей лидирующего нуля. Если API требует строгого контракта, неявное приведение типов необходимо принудительно отключать.


2. Строгая типизация: управление strict-режимом в Pydantic v2

Pydantic v2 позволяет включить строгий режим (strict mode) на уровне отдельных полей, специальных типов или всей модели целиком.

Field(strict=True) и типы Strict*

Для точечного запрета приведения типов используются типы StrictInt, StrictStr, StrictBool, StrictFloat либо параметр strict=True внутри функции Field:

from pydantic import BaseModel, Field, StrictInt, StrictStr

class ItemPayload(BaseModel):
    # Строго строка: число или boolean вызовут ошибку валидации
    sku: StrictStr
    
    # Строго целое число: строка "10" вызовет ошибку
    quantity: StrictInt
    
    # Альтернативный синтаксис через Field
    price_cents: int = Field(..., strict=True, gt=0)

Если клиент передаст {"sku": 100200, "quantity": "5", "price_cents": 150}, Pydantic не станет приводить типы и сразу вернет ошибку валидации для полей sku и quantity.

Включение strict-режима на уровне модели через ConfigDict

Если строгим должен быть весь контракт целиком, правило задается глобально для модели через model_config:

from pydantic import BaseModel, ConfigDict

class StrictOrderCreate(BaseModel):
    model_config = ConfigDict(strict=True)

    order_id: int
    customer_code: str
    is_urgent: bool

Все поля модели StrictOrderCreate будут требовать точного соответствия типов без попыток автоматического парсинга несовместимых значений.


3. Вложенные модели и коллекции: валидация сложных структур

В реальных API полезная нагрузка редко бывает плоской. Чаще всего запрос представляет собой дерево: заказ содержит данные покупателя, адрес доставки и список позиций, каждая из которых имеет свои атрибуты.

Типизация списков объектов: list[OrderItem]

В Pydantic v2 вложенность организуется через композицию классов и стандартную типизацию Python:

from pydantic import BaseModel, Field

class OrderItem(BaseModel):
    sku: str = Field(..., min_length=3, max_length=30)
    quantity: int = Field(..., gt=0)
    unit_price: float = Field(..., gt=0)

class DeliveryAddress(BaseModel):
    city: str
    street: str
    postal_code: str = Field(..., pattern=r"^\d{6}$")

class OrderCreateRequest(BaseModel):
    customer_id: int = Field(..., gt=0)
    address: DeliveryAddress
    items: list[OrderItem] = Field(..., min_length=1)

FastAPI автоматически рекурсивно проверит:

  1. Корневой объект OrderCreateRequest.
  2. Вложенный объект address.
  3. Каждый элемент в массиве items (а также проконтролирует наличие хотя бы одного элемента в списке).

Позиционирование ошибок в коллекциях: чтение кортежа loc

Если в массиве из 10 элементов ошибка допущена во втором объекте в свойстве unit_price, Pydantic сформирует в loc точный путь:

("body", "items", 1, "unit_price")

Где items — имя поля, 1 — индекс элемента в списке (нумерация с нуля), а unit_price — целевое невалидное свойство.


4. Бизнес-валидация: декораторы @field_validator и @model_validator

Встроенных проверок типов и ограничений Field (вроде gt, min_length, pattern) часто недостаточно. Для реализации бизнес-логики в Pydantic v2 используются декораторы @field_validator и @model_validator.

Одиночные поля с @field_validator

Декоратор @field_validator проверяет или нормализует значение конкретного поля:

from pydantic import BaseModel, field_validator

class UserRegisterRequest(BaseModel):
    username: str
    promo_code: str | None = None

    @field_validator("username")
    @classmethod
    def validate_username(cls, value: str) -> str:
        cleaned = value.strip().lower()
        if not cleaned.isalnum():
            raise ValueError("Имя пользователя должно содержать только буквы и цифры")
        return cleaned

    @field_validator("promo_code")
    @classmethod
    def normalize_promo(cls, value: str | None) -> str | None:
        if value is not None:
            return value.strip().upper()
        return value

По умолчанию валидатор поля срабатывает после базовой проверки типов (mode='after'). Если требуется обработать сырые входные данные до приведения типов, используется аргумент mode='before'.

Кросс-валидация нескольких полей через @model_validator

Когда корректность одного поля зависит от значения другого (например, дата начала не может быть позже даты окончания), применяется @model_validator(mode='after'):

from datetime import date
from pydantic import BaseModel, model_validator

class BookingRequest(BaseModel):
    start_date: date
    end_date: date
    guests_count: int

    @model_validator(mode="after")
    def verify_date_range(self) -> "BookingRequest":
        if self.end_date <= self.start_date:
            raise ValueError("Дата окончания бронирования должна быть строго позже даты начала")
        return self

5. Единый контракт ошибок: кастомный обработчик RequestValidationError

Чтобы клиенты API получали предсказуемый ответ, стандартный перехватчик FastAPI переопределяется через кастомный exception_handler.

Преобразование кортежа loc в читаемую точечную нотацию

Вместо сырого массива ["body", "items", 1, "unit_price"] клиенту удобнее передавать путь формата items[1].unit_price.

Функция форматирования пути:

def format_loc(loc: tuple[int | str, ...]) -> str:
    # Исключаем служебные префиксы из начала пути
    parts = [part for part in loc if part not in ("body", "query", "path", "header", "cookie")]
    
    if not parts:
        return "__root__"

    formatted_path = ""
    for part in parts:
        if isinstance(part, int):
            formatted_path += f"[{part}]"
        else:
            if formatted_path:
                formatted_path += f".{part}"
            else:
                formatted_path = str(part)
    return formatted_path

Реализация кастомного Exception Handler

Зарегистрируем обработчик для RequestValidationError на уровне экземпляра FastAPI:

from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel

app = FastAPI(title="Order API")

class ErrorDetail(BaseModel):
    field: str
    message: str
    error_type: str

class ValidationErrorResponse(BaseModel):
    status: str = "error"
    errors: list[ErrorDetail]

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request, 
    exc: RequestValidationError
) -> JSONResponse:
    errors_list: list[dict[str, str]] = []

    for err in exc.errors():
        field_path = format_loc(err.get("loc", ()))
        message = err.get("msg", "Некорректное значение")
        
        # Очистка стандартного префикса Pydantic при ValueError
        if message.startswith("Value error, "):
            message = message.replace("Value error, ", "")

        errors_list.append({
            "field": field_path,
            "message": message,
            "error_type": err.get("type", "validation_error")
        })

    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content={
            "status": "error",
            "errors": errors_list
        }
    )

Теперь при отправке некорректного тела запроса клиент получит стандартизированный ответ:

{
  "status": "error",
  "errors": [
    {
      "field": "address.postal_code",
      "message": "String should match pattern '^\\d{6}$'",
      "error_type": "string_pattern_mismatch"
    },
    {
      "field": "items[0].quantity",
      "message": "Input should be greater than 0",
      "error_type": "greater_than"
    }
  ]
}

6. Тестирование валидационного контракта с pytest

Каждое правило валидации и контракт ошибок должны проверяться автоматическими тестами. Для этого используется pytest и TestClient из fastapi.testclient.

Пример тестового набора

import pytest
from fastapi.testclient import TestClient

from main import app

client = TestClient(app)

def test_create_order_success():
    payload = {
        "customer_id": 10,
        "address": {
            "city": "Москва",
            "street": "Тверская, 1",
            "postal_code": "125009"
        },
        "items": [
            {"sku": "SKU-100", "quantity": 2, "unit_price": 499.90}
        ]
    }
    response = client.post("/orders", json=payload)
    assert response.status_code == 200
    assert response.json()["status"] == "created"

def test_validation_error_nested_and_indexed_fields():
    invalid_payload = {
        "customer_id": -1,  # Невалидный ID (<= 0)
        "address": {
            "city": "Москва",
            "street": "Тверская, 1",
            "postal_code": "ABC"  # Невалидный индекс (не 6 цифр)
        },
        "items": [
            {"sku": "OK", "quantity": 0, "unit_price": 100.0}  # quantity <= 0
        ]
    }
    response = client.post("/orders", json=invalid_payload)
    
    assert response.status_code == 422
    data = response.json()
    
    assert data["status"] == "error"
    errors = {item["field"]: item["message"] for item in data["errors"]}
    
    # Проверяем корректность путей к полям
    assert "customer_id" in errors
    assert "address.postal_code" in errors
    assert "items[0].quantity" in errors

7. Частые вопросы (FAQ)

В чем разница между strict=True в Pydantic v2 и дефолтным поведением?

По умолчанию Pydantic пытается безопасно преобразовать типы (например, строку "10" в число 10). В режиме strict=True приведение типов полностью запрещено: если поле ожидает int, а передана строка или float, валидация немедленно вернет ошибку.

Когда использовать @field_validator, а когда @model_validator?

@field_validator предназначен для проверки и трансформации одного конкретного поля (проверка формата строки, очистка пробелов, валидация e-mail). @model_validator используется для кросс-валидации, когда логика требует сравнения значений двух и более полей (например, сравнение даты начала и окончания).

Можно ли возвращать статус 400 вместо 422 для ошибок валидации?

Да. Для этого в кастомном обработчике validation_exception_handler достаточно изменить аргумент status_code в объекте JSONResponse на status.HTTP_400_BAD_REQUEST.

Как задокументировать кастомный формат ошибки 422 в OpenAPI (Swagger UI)?

По умолчанию FastAPI описывает стандартную схему HTTPValidationError. Чтобы схема в Swagger отражала ваш кастомный формат, передайте параметр responses в декоратор эндпоинта:

@app.post(
    "/orders", 
    responses={422: {"model": ValidationErrorResponse, "description": "Validation Error"}}
)
async def create_order(payload: OrderCreateRequest):
    ...

Вывод

Грамотно настроенная валидация защищает сервисный слой приложения от некорректных данных и упрощает интеграцию для клиентских разработчиков.

Использование строгого режима (strict=True) исключает непреднамеренное приведение типов, композиция моделей обеспечивает проверку вложенных коллекций любой глубины, а единый обработчик RequestValidationError с форматированием путей loc превращает технические сообщения Pydantic в понятный и стабильный контракт ошибок. Покрытие этих правил тестами pytest гарантирует надежность API на всех этапах развития проекта.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также