When you write an API in FastAPI, the documentation appears on its own — at /docs and /openapi.json. But that doesn't mean it will be good. Let's look at how to control what gets generated, and which mistakes to avoid.
How FastAPI builds OpenAPI
In the Java world, OpenAPI is usually written by hand: first a YAML spec, then code generated from it. In FastAPI it's the other way around: the code is the source of the spec. Pydantic models and route decorators automatically turn into /openapi.json.
This is convenient: there's no need to keep the YAML in sync with the code. But it also means that if the code is written carelessly, the spec will be just as careless.
operation_id: the operation name
Every route in OpenAPI has an identifier — operation_id. If you don't set it explicitly, FastAPI generates it itself from the method and path:
post_orders_order_id_confirm_api_v1_orders__order_id__confirm_post
This is a problem: SDK generators (openapi-generator, openapi-python-client) use operation_id as the method name of the client class. Such an identifier produces unreadable code.
Set operation_id explicitly in the action + resource format in camelCase:
router = APIRouter(prefix="/api/v1", tags=["Orders"])
@router.get("/orders", operation_id="getOrders", summary="List orders")
async def get_orders(...) -> PageResponse[OrderResponse]: ...
@router.post("/orders", operation_id="createOrder", status_code=201, summary="Create order")
async def create_order(...) -> OrderResponse: ...
@router.get("/orders/{order_id}", operation_id="getOrder", summary="Get order")
async def get_order(order_id: UUID) -> OrderResponse: ...
@router.post("/orders/{order_id}/confirm", operation_id="confirmOrder", summary="Confirm order")
async def confirm_order(order_id: UUID) -> OrderResponse: ...
The convention:
getOrder/getOrders— get one or a listcreateOrder— creation via POSTupdateOrder— full replacement via PUTpatchOrder— partial update via PATCHdeleteOrder— deletionconfirmOrder,cancelOrder— an action on a resource
tags: grouping in Swagger UI
Without tags, Swagger UI shows a flat list of all routes with no structure. Tags group routes by resource.
The rule: one tag per resource, plural and capitalized — Orders, Products, Customers. Nested actions (confirm, cancel) belong to the tag of the parent resource, not to a separate OrderActions tag.
app = FastAPI(
openapi_tags=[
{"name": "Orders", "description": "Order management"},
{"name": "Products", "description": "Product catalog"},
]
)
orders_router = APIRouter(prefix="/api/v1", tags=["Orders"])
# Correct: an action endpoint under the parent resource's tag
@orders_router.post(
"/orders/{order_id}/confirm",
operation_id="confirmOrder",
tags=["Orders"],
summary="Confirm order",
)
async def confirm_order(order_id: UUID) -> OrderResponse: ...
Path parameters with nested resources
When you have a nested route like /orders/{id}/items/{id}, FastAPI can't work with identical parameter names — and Swagger UI will break too. Name the parameters uniquely:
@router.get(
"/orders/{order_id}/items/{item_id}",
operation_id="getOrderItem",
summary="Order item",
)
async def get_order_item(
order_id: UUID,
item_id: UUID,
) -> OrderItemResponse: ...
summary and description: what the developer sees
summary is a short string that Swagger UI shows next to the route. Without it, only the path is visible. Keep it under 80 characters.
Add description only if the logic is non-trivial — for example, which conditions must be met before the call. An empty string is worse than none.
@router.post(
"/orders/{order_id}/confirm",
operation_id="confirmOrder",
summary="Confirm order",
description="""
Moves the order from status `CREATED` to `CONFIRMED`.
Requirements:
- the order contains at least one item;
- all items are in stock.
After confirmation, changing the order's contents is impossible.
""",
)
async def confirm_order(order_id: UUID) -> OrderResponse: ...
Pydantic models as the contract
In the code-first approach, OpenAPI schemas are generated from Pydantic models. A correctly written model is already a correct spec, with no extra YAML.
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
from uuid import UUID
from datetime import datetime
class OrderStatus(StrEnum):
CREATED = "CREATED"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
DELIVERED = "DELIVERED"
CANCELLED = "CANCELLED"
class OrderResponse(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
order_id: UUID
customer_id: UUID
status: OrderStatus
created_at: datetime
confirmed_at: datetime | None = None
alias_generator=to_camel means that in JSON the fields will be in camelCase (orderId, customerId), even though in Python code they're snake_case. This is needed because the REST contract expects camelCase.
To keep optional fields from appearing in the response as null, add response_model_exclude_none=True to the route:
@router.get(
"/orders/{order_id}",
operation_id="getOrder",
summary="Get order",
response_model_exclude_none=True,
)
async def get_order(order_id: UUID) -> OrderResponse: ...
When creating a resource, return a Location header with the URL of the new object:
from fastapi import Response
@router.post(
"/customers",
operation_id="createCustomer",
status_code=201,
summary="Register customer",
response_model_exclude_none=True,
)
async def create_customer(body: CreateCustomerRequest, response: Response) -> CustomerResponse:
customer = await customer_service.create(body)
response.headers["Location"] = f"/api/v1/customers/{customer.customer_id}"
return customer
Validation error: 422 instead of 400
By default, FastAPI returns 422 Unprocessable Entity on a Pydantic error. This is non-standard: clients expect 400 Bad Request for input errors. Override the handler:
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import Response
import json
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request,
exc: RequestValidationError,
) -> Response:
violations = [
{
"field": ".".join(str(loc) for loc in err["loc"] if loc != "body"),
"message": err["msg"],
"rejectedValue": err.get("input"),
}
for err in exc.errors()
]
body = {
"type": "urn:problem:orders:validation-error",
"title": "Validation Error",
"status": 400,
"code": "VALIDATION_ERROR",
"violations": violations,
}
return Response(
content=json.dumps(body, ensure_ascii=False),
status_code=400,
media_type="application/problem+json",
)
Common design mistakes
URL
A verb in the URL for CRUD — bad: /createOrder. Good: POST /api/v1/orders. The HTTP method already carries the meaning of the action.
Wrong path casing — camelCase (/orderItems) and snake_case (/order_items) in a URL are a mistake. Use kebab-case: /order-items.
Trailing slash — /orders/ instead of /orders. Causes redirects and confusion.
Deep nesting — /orders/{id}/items/{id}/variants/{id} is hard to read and maintain. Deeper than two levels is a sign you need a filter at the top level.
GET with a side effect — GET /orders/{id}/cancel violates idempotency. For actions, use POST /orders/{id}/cancel.
Versioning
Version in the query — ?version=2 instead of /api/v2/orders. The version should be in the path.
Minor version — /api/v1.2/. Only major versions: v1, v2.
A new version for the sake of an optional field — if a field can be added without breaking existing clients, do it in the current version.
Query parameters
CSV arrays — ?ids=1,2,3 is hard to parse on some platforms. Use repetition: ?ids=1&ids=2&ids=3.
snake_case in parameters — ?customer_id= instead of ?customerId=. In FastAPI, set an alias via Query(alias="customerId").
Zero-based numbering — ?page=0 confuses API users. Start the public contract with page=1.
Responses
Envelope — {"success": true, "data": {...}} is an unnecessary wrapper. Return the resource directly.
null in the response — a field with null takes up space and confuses clients. Exclude it via response_model_exclude_none=True.
Empty string instead of an absent field — "" is not "the field is absent," it's an empty value. Different semantics.
Errors
Wrong Content-Type — for errors, use application/problem+json, not application/json.
Opaque type — "type": "about:blank" is useless. Use "urn:problem:<service>:<code>".
Stack trace in a 500 body — never expose internal implementation details. Only a general code and a clear detail.
OpenAPI metadata
FastAPI auto-id — long strings like post_orders_order_id_confirm_api_v1_orders__order_id__confirm_post. Set operation_id explicitly.
Missing tags — Swagger UI will show a flat list with no grouping.
Missing summary — in Swagger UI, only the path will appear next to the route, with no description.
Custom headers
Don't use the X- prefix for custom headers (it was deprecated in RFC 6648). Instead of X-Request-Id, use Shop-Request-Id or your service's domain prefix.
Localization
Technical identifiers — error codes, enum values, JSON keys, URLs — must be in English. VALIDATION_ERROR, not ОШИБКА_ВАЛИДАЦИИ. Only user-facing messages in the detail field are subject to localization.
In short
- FastAPI generates OpenAPI automatically from code and Pydantic models — manage the spec's quality through decorators.
- Always set
operation_idexplicitly in camelCase (getOrders,confirmOrder) — otherwise you'll get unreadable auto-names. - One tag per resource, plural and capitalized (
Orders). Action endpoints go under the parent resource's tag. - For nested routes, name parameters uniquely:
{order_id},{item_id}. summaryis required,description— only if the logic is non-trivial.- Override the
RequestValidationErrorhandler: by default FastAPI returns422, but you want400. response_model_exclude_none=Trueremovesnullfields from the response.- A verb in the URL is a mistake; the HTTP method (GET/POST/PUT/DELETE) already carries the meaning.
- Error codes and enum values — English only.
Further reading
- URL and resources — path structure, nesting, resource naming.
- Errors and RFC 9457 — problem+json, overriding 422 → 400.
- JSON and response format — exclude_none, camelCase, the envelope antipattern.
- Query parameters and pagination — camelCase aliases, cursor pagination.