← Back to the section

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 list
  • createOrder — creation via POST
  • updateOrder — full replacement via PUT
  • patchOrder — partial update via PATCH
  • deleteOrder — deletion
  • confirmOrder, 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 casingcamelCase (/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 effectGET /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_id explicitly 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}.
  • summary is required, description — only if the logic is non-trivial.
  • Override the RequestValidationError handler: by default FastAPI returns 422, but you want 400.
  • response_model_exclude_none=True removes null fields 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.