← Back to the section

A REST API is a contract between server and client. If a field is called created_at in one response and createdAt in another, the client breaks. If a date arrives without a time zone, it's useless. This guide is about how FastAPI and Pydantic help make JSON responses predictable.

Why fields in JSON are named differently from Python

Python code is written in snake_case: order_id, created_at, total_amount. That's the language convention, and it's inconvenient to depart from it.

But REST API clients are most often JavaScript and TypeScript, where camelCase is the norm: orderId, createdAt, totalAmount. If the server returns order_id, the frontend developer either puts up with an awkward name or renames it everywhere — extra work on both sides.

Pydantic solves this with alias_generator: Python fields stay snake_case, but in JSON they automatically turn into 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,
    )

This code will return:

{
  "orderId": "550e8400-...",
  "createdAt": "2026-05-26T10:30:00Z",
  "totalAmount": 1500.00
}

populate_by_name=True is an extra option that lets you build the model both by its snake_case name and by its camelCase alias. That's handy inside Python code.

Dates and times: why the time zone matters

A date without a time zone is an imprecise date. "2026-05-26 10:30:00" — is that Moscow? UTC? The server's local time? When an application runs across several regions, or the client is in a different time zone, such strings breed bugs.

The rule is simple: always UTC, always ISO 8601 with a Z suffix.

Pydantic v2 serializes datetime to ISO 8601 automatically. You just need to create the object correctly:

from datetime import datetime, timezone

created_at = datetime.now(timezone.utc)  # correct

What's good and what's bad:

"2026-05-26T10:30:00Z"       ✓ — UTC, ISO 8601
"2026-05-26"                 ✓ — date only, no time
"2026-05-26 10:30:00"        ✗ — no T, no time zone
"2026-05-26T10:30:00"        ✗ — no time zone

Statuses and categories: enums as strings

An order's status field can take a fixed set of values: CREATED, CONFIRMED, SHIPPED. Storing them as plain strings is dangerous: a typo like "confirmd" would pass validation unnoticed.

Python solves this with enum. For JSON serialization in FastAPI, StrEnum is a good fit — its values serialize directly as strings, with no extra configuration:

from enum import StrEnum

class OrderStatus(StrEnum):
    CREATED = "CREATED"
    CONFIRMED = "CONFIRMED"
    IN_PROGRESS = "IN_PROGRESS"
    SHIPPED = "SHIPPED"
    DELIVERED = "DELIVERED"
    CANCELLED = "CANCELLED"

In JSON the field will arrive as "CONFIRMED" or "IN_PROGRESS" — strings in UPPER_SNAKE_CASE. The client can check the value with a simple comparison.

Identifiers: the Id suffix

Identifier fields are named with an Id suffix: orderId, customerId, productId. This convention makes it immediately clear that we're looking at a reference to an entity, not just a string.

class OrderResponse(BaseModel):
    order_id: str      # → orderId in JSON
    customer_id: str   # → customerId
    product_id: str    # → productId

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

Null in responses: better to drop the field entirely

When an order has no comment, what's better to return: "comment": null or not include the comment field at all?

null is ambiguous. The client doesn't know: does the value not exist, is it unset, was it deleted, or did something go wrong? An absent field reads unambiguously: there's no data.

In FastAPI this is configured with the response_model_exclude_none=True parameter:

@router.get(
    "/orders/{order_id}",
    response_model=OrderResponse,
    response_model_exclude_none=True,
)
async def get_order(order_id: str) -> OrderResponse:
    ...

Fields with a value of None simply won't make it into the JSON response.

An alternative is to configure this at the model level via 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,
    )

Exception: in the body of a PATCH request, null has a special meaning — a command to delete the field. This is the JSON Merge Patch standard (RFC 7396):

PATCH /api/v1/orders/{order_id}
Content-Type: application/merge-patch+json

{ "comment": null }

Here null is a deliberate operation. In server responses, null is still not needed.

Response structure: no extra wrappers

You often see a format like this:

{
  "success": true,
  "data": { "orderId": "550e8400-..." },
  "error": null
}

This is called an "envelope." The idea is understandable: unify the format. But HTTP already does this through status codes: 200 — success, 404 — not found, 422 — validation error. Adding "success": true duplicates that information.

The client is forced to write response.data.orderId instead of response.orderId. That's an extra level of nesting.

The right format is a flat object:

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": []
}

Lists and pagination

When an endpoint returns a collection, it's wrapped in an object with a content field and pagination metadata:

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
}

An important point: an empty collection is [], not null:

class OrderResponse(BaseModel):
    items: list[OrderItemResponse] = []   # empty list by default
{ "items": [] }

Response codes for different operations

Different HTTP operations return different codes:

Creating a resource — 201 with a Location header:

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)

Updating — 200 with the updated resource:

@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:
    ...

Deleting — 204 with no body:

@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)

There's no need to return {"success": True} on deletion — the 204 code already signals success.

Common mistakes

No alias_generator — fields in JSON stay in snake_case. The frontend gets order_id instead of orderId.

datetime without a time zone — use datetime.now(timezone.utc), not datetime.now(). Without timezone.utc the time is local and the time zone is lost.

Optional[str] instead of str | None — in Pydantic v2, the str | None = None syntax is preferred.

null in a collectionitems: list | None = None means that an empty result will arrive as null. The right way: items: list = [].

Envelope{"success": True, "data": ...} complicates client code with no benefit.

In short

  • camelCase in JSON is configured via ConfigDict(alias_generator=to_camel, populate_by_name=True) — Python stays in snake_case, the client gets camelCase.
  • Dates — always UTC and ISO 8601; Pydantic v2 serializes datetime automatically; create them via datetime.now(timezone.utc).
  • Enum valuesStrEnum with UPPER_SNAKE_CASE; serialized as a string with no extra configuration.
  • Identifiers — the Id suffix: orderId, customerId.
  • null in responses is forbidden — better to drop the field entirely via response_model_exclude_none=True.
  • null in a PATCH body — a legitimate operation: a command to delete the field (JSON Merge Patch RFC 7396).
  • Envelope is forbidden — a flat object; the HTTP status already signals success or failure.
  • Collection{ "content": [...] } with pagination metadata; an empty collection is [], not null.

Further reading

  • URL and resources in FastAPI — HTTP methods and route structure.
  • Query parameters and pagination in FastAPI — how to pass page parameters.
  • Errors and RFC 9457 in FastAPI — the error response format.
  • Headers and tracing in FastAPI — Location and other headers.