REST API — это договор между сервером и клиентом. Если поле в ответе называется created_at в одном запросе и createdAt в другом, клиент сломается. Если дата приходит без часового пояса — она бесполезна. Этот гайд о том, как FastAPI и Pydantic помогают сделать JSON-ответы предсказуемыми.
Почему поля в JSON называются иначе, чем в Python
Python-код пишут в snake_case: order_id, created_at, total_amount. Это соглашение языка, и отступать от него неудобно.
Но клиенты REST API — чаще всего JavaScript и TypeScript, где принято camelCase: orderId, createdAt, totalAmount. Если сервер отдаёт order_id, фронтенд-разработчик либо терпит неудобное имя, либо везде переименовывает — это лишняя работа с обеих сторон.
Pydantic решает это через alias_generator: Python-поля остаются snake_case, а в JSON автоматически превращаются в camelCase.
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
from datetime import datetime
class OrderResponse(BaseModel):
order_id: str
created_at: datetime
total_amount: float
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
Этот код вернёт:
{
"orderId": "550e8400-...",
"createdAt": "2026-05-26T10:30:00Z",
"totalAmount": 1500.00
}
populate_by_name=True — дополнительная опция, которая позволяет создавать модель как по snake_case-имени, так и по camelCase-псевдониму. Это удобно внутри Python-кода.
Даты и время: почему важен часовой пояс
Дата без часового пояса — неточная дата. "2026-05-26 10:30:00" — это Москва? UTC? Локальное время сервера? Когда приложение работает в нескольких регионах или клиент находится в другом часовом поясе, такие строки порождают ошибки.
Правило простое: всегда UTC, всегда ISO 8601 с суффиксом Z.
Pydantic v2 сериализует datetime в ISO 8601 автоматически. Достаточно правильно создать объект:
from datetime import datetime, timezone
created_at = datetime.now(timezone.utc) # правильно
Что хорошо и что плохо:
"2026-05-26T10:30:00Z" ✓ — UTC, ISO 8601
"2026-05-26" ✓ — только дата, без времени
"2026-05-26 10:30:00" ✗ — нет T, нет часового пояса
"2026-05-26T10:30:00" ✗ — нет часового пояса
Статусы и категории: enum как строки
Поле status у заказа может принимать фиксированный набор значений: CREATED, CONFIRMED, SHIPPED. Хранить их как обычные строки — опасно: опечатка "confirmd" пройдёт валидацию незаметно.
Python решает это через enum. Для JSON-сериализации в FastAPI подходит StrEnum — его значения сериализуются напрямую как строки, без дополнительных настроек:
from enum import StrEnum
class OrderStatus(StrEnum):
CREATED = "CREATED"
CONFIRMED = "CONFIRMED"
IN_PROGRESS = "IN_PROGRESS"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
CANCELLED = "CANCELLED"
В JSON поле придёт как "CONFIRMED" или "IN_PROGRESS" — строки в UPPER_SNAKE_CASE. Клиент может проверить значение простым сравнением.
Идентификаторы: суффикс Id
Поля-идентификаторы называются с суффиксом Id: orderId, customerId, productId. Это соглашение помогает сразу понять, что перед нами — ссылка на сущность, а не просто строка.
class OrderResponse(BaseModel):
order_id: str # → orderId в JSON
customer_id: str # → customerId
product_id: str # → productId
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
Null в ответах: поле лучше убрать совсем
Когда у заказа нет комментария, что лучше вернуть: "comment": null или вообще не включать поле comment в ответ?
null — это неоднозначно. Клиент не знает: значение не существует, не задано, удалено или что-то пошло не так. Отсутствие поля читается однозначно: данных нет.
В FastAPI это настраивается параметром response_model_exclude_none=True:
@router.get(
"/orders/{order_id}",
response_model=OrderResponse,
response_model_exclude_none=True,
)
async def get_order(order_id: str) -> OrderResponse:
...
Поля со значением None просто не попадут в JSON-ответ.
Альтернатива — настроить это на уровне модели через ConfigDict:
class OrderResponse(BaseModel):
order_id: str
comment: str | None = None
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
exclude_none=True,
)
Исключение: в теле PATCH-запроса null имеет особый смысл — команда удалить поле. Это стандарт JSON Merge Patch (RFC 7396):
PATCH /api/v1/orders/{order_id}
Content-Type: application/merge-patch+json
{ "comment": null }
Здесь null — намеренная операция. В ответах сервера null всё равно не нужен.
Структура ответа: без лишних обёрток
Часто видят такой формат:
{
"success": true,
"data": { "orderId": "550e8400-..." },
"error": null
}
Это называют «конвертом» (envelope). Идея понятна: унифицировать формат. Но HTTP уже делает это за счёт кодов ответа: 200 — успех, 404 — не найдено, 422 — ошибка валидации. Добавлять "success": true — дублировать информацию.
Клиент вынужден писать response.data.orderId вместо response.orderId. Это лишний уровень вложенности.
Правильный формат — плоский объект:
class OrderResponse(BaseModel):
order_id: str
status: OrderStatus
total_amount: float
created_at: datetime
items: list[OrderItemResponse] = []
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
{
"orderId": "550e8400-...",
"status": "CONFIRMED",
"totalAmount": 1500.00,
"createdAt": "2026-05-26T10:30:00Z",
"items": []
}
Списки и пагинация
Когда эндпоинт возвращает коллекцию, она оборачивается в объект с полем content и метаданными пагинации:
class PaginatedOrders(BaseModel):
content: list[OrderResponse]
page: int
size: int
total_elements: int
total_pages: int
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
{
"content": [...],
"page": 0,
"size": 20,
"totalElements": 143,
"totalPages": 8
}
Важный момент: пустая коллекция — это [], не null:
class OrderResponse(BaseModel):
items: list[OrderItemResponse] = [] # пустой список по умолчанию
{ "items": [] }
Коды ответа для разных операций
Разные HTTP-операции возвращают разные коды:
Создание ресурса — 201 с заголовком Location:
from fastapi import Response
@router.post(
"/orders",
status_code=201,
response_model=OrderResponse,
response_model_exclude_none=True,
)
async def create_order(body: CreateOrderRequest, response: Response) -> OrderResponse:
order = await use_cases.create_order(body)
response.headers["Location"] = f"/api/v1/orders/{order.order_id}"
return OrderResponse.model_validate(order)
Обновление — 200 с обновлённым ресурсом:
@router.put(
"/orders/{order_id}",
status_code=200,
response_model=OrderResponse,
response_model_exclude_none=True,
)
async def update_order(order_id: str, body: UpdateOrderRequest) -> OrderResponse:
...
Удаление — 204 без тела:
@router.delete("/orders/{order_id}", status_code=204)
async def delete_order(order_id: str):
await use_cases.delete_order(order_id)
return Response(status_code=204)
Возвращать {"success": True} при удалении не нужно — код 204 уже говорит об успехе.
Частые ошибки
Нет alias_generator — поля в JSON остаются в snake_case. Фронтенд получает order_id вместо orderId.
datetime без часового пояса — используйте datetime.now(timezone.utc), а не datetime.now(). Без timezone.utc время локальное, часовой пояс теряется.
Optional[str] вместо str | None — в Pydantic v2 предпочтителен синтаксис str | None = None.
null в коллекции — items: list | None = None означает, что при пустом результате придёт null. Правильно: items: list = [].
Envelope — {"success": True, "data": ...} усложняет клиентский код без пользы.
Коротко
- camelCase в JSON настраивается через
ConfigDict(alias_generator=to_camel, populate_by_name=True)— Python остаётся вsnake_case, клиент получаетcamelCase. - Даты — всегда UTC и ISO 8601; Pydantic v2 сериализует
datetimeавтоматически; создавайте черезdatetime.now(timezone.utc). - Enum-значения —
StrEnumсUPPER_SNAKE_CASE; сериализуется как строка без дополнительных настроек. - Идентификаторы — суффикс
Id:orderId,customerId. nullв ответах запрещён — поле лучше убрать совсем черезresponse_model_exclude_none=True.nullв PATCH-теле — законная операция: команда удалить поле (JSON Merge Patch RFC 7396).- Envelope запрещён — плоский объект, HTTP-статус уже говорит об успехе или ошибке.
- Коллекция —
{ "content": [...] }с метаданными пагинации; пустая коллекция —[], неnull.
Что почитать дальше
- URL и ресурсы в FastAPI — HTTP-методы и структура маршрутов.
- Query-параметры и пагинация в FastAPI — как передать параметры страницы.
- Ошибки и RFC 9457 в FastAPI — формат error-ответов.
- Заголовки и трассировка в FastAPI —
Locationи другие заголовки.