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 collection — items: 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 insnake_case, the client getscamelCase. - Dates — always UTC and ISO 8601; Pydantic v2 serializes
datetimeautomatically; create them viadatetime.now(timezone.utc). - Enum values —
StrEnumwithUPPER_SNAKE_CASE; serialized as a string with no extra configuration. - Identifiers — the
Idsuffix:orderId,customerId. nullin responses is forbidden — better to drop the field entirely viaresponse_model_exclude_none=True.nullin 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[], notnull.
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 —
Locationand other headers.