Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: API design & OpenAPI

Code can be refactored in an afternoon. A public API cannot: other teams, mobile apps already in users' pockets, and partners' scripts depend on every field name…

In this post

What this post covers
  1. 1. Resources, verbs and status codes
  2. 2. A well-described API, run for real
  3. 3. Versioning and evolution
  4. 4. Docs in production
  5. 5. Details that separate good APIs from great ones
  6. Recap

Code can be refactored in an afternoon. A public API cannot: other teams, mobile apps already in users’ pockets, and partners’ scripts depend on every field name and status code. Senior engineers therefore spend real effort on the shape of an API: resources and verbs, status codes, partial updates, versioning, and a machine-readable contract that client generators can use. FastAPI writes the OpenAPI document for you, and this part shows how to make that document good.

🔌

A wall socket standard

Appliance makers and electricians never meet, yet every plug fits because the socket is a published standard that changes very rarely, and when it does, both kinds exist side by side for years. Your API is the socket and the OpenAPI document is the standard. Break it without a transition period and every "appliance" in the field stops working.

time /v1 released + optional field + new endpoint Deprecation, Sunset traffic → 0 /v2 rename a field (breaking) → only here both served in parallel additive changes are safe inside a version CI diffs openapi.json on every release

1. Resources, verbs and status codes

OperationRequestSuccessNotes
listGET /v1/orders?status=paid&limit=50&cursor=…200always paginate (part 16); filters are query parameters
createPOST /v1/orders201 + Locationaccept an Idempotency-Key
readGET /v1/orders/{id}200 + ETag404 for missing and for "not yours"
partial updatePATCH /v1/orders/{id}200only fields that were sent change (exclude_unset); If-Match
replacePUT /v1/orders/{id}200the full representation; rarely needed
deleteDELETE /v1/orders/{id}204repeat deletes should also succeed (204 or 404, documented)
actionPOST /v1/orders/{id}/cancel200state transitions are clearer as sub-resources than as PATCH {"status": …}
long jobPOST /v1/reports202 + Location: /v1/jobs/{id}the client polls or subscribes (part 14)

400 vs 422

422: the body is well-formed but invalid (FastAPI's default). 400: a business rule failed, for example "cannot cancel a shipped order". Many teams use 409 for state conflicts.

401 vs 403

401: we don't know who you are (send WWW-Authenticate). 403: we know you, and you may not. For other people's objects, prefer 404.

Plural nouns

/orders/{id}/items, not /getOrderItems. Keep nesting to one level; deeper relations use filters (/items?order_id=…).

2. A well-described API, run for real

This app shows the design features together: versioned routers, camelCase JSON on the wire with snake_case in Python, PATCH semantics, 201 with Location, a documented error model, examples, deprecation and clean operation ids. It then inspects its own OpenAPI document.

design features, then the generated OpenAPI

from datetime import datetime, timezone
from typing import Annotated, Literal
from fastapi import APIRouter, Body, FastAPI, HTTPException, Response
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

class ApiModel(BaseModel):
    """Base for every public model: camelCase on the wire, snake_case in Python."""
    model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True, extra="forbid")

class Problem(ApiModel):                        # the RFC 9457 error shape (lesson 09)
    type: str = "about:blank"
    title: str
    status: int
    detail: str | None = None

class OrderIn(ApiModel):
    customer_email: str
    gift_message: str | None = None
    delivery_slot: Literal["morning", "evening"] = "morning"

class OrderPatch(ApiModel):
    gift_message: str | None = None
    delivery_slot: Literal["morning", "evening"] | None = None

class OrderOut(ApiModel):
    id: int
    customer_email: str
    gift_message: str | None
    delivery_slot: str
    created_at: datetime

NOT_FOUND = {404: {"model": Problem, "description": "No such order (or not yours)"}}
CONFLICT = {409: {"model": Problem, "description": "The order is no longer editable"}}

def operation_id(route: APIRoute) -> str:       # "orders-create" instead of "create_order_v1_orders_post"
    return f"{route.tags[0]}-{route.name}"

app = FastAPI(title="Orders API", version="1.4.0", generate_unique_id_function=operation_id,
              description="Orders for the Brew & Bean shop. All times are UTC.",
              openapi_tags=[{"name": "orders", "description": "Create and manage orders"}])
v1 = APIRouter(prefix="/v1/orders", tags=["orders"])
DB: dict[int, dict] = {}

@v1.post("", status_code=201, summary="Create an order")
def create(body: Annotated[OrderIn, Body(openapi_examples={
        "gift": {"summary": "A gift order", "value": {"customerEmail": "[email protected]",
                                                        "giftMessage": "Happy birthday!"}}})],
           response: Response) -> OrderOut:
    order_id = len(DB) + 1
    DB[order_id] = {"id": order_id, **body.model_dump(), "created_at": datetime(2026, 9, 19, tzinfo=timezone.utc)}
    response.headers["Location"] = f"/v1/orders/{order_id}"
    return DB[order_id]

@v1.get("/{order_id}", responses=NOT_FOUND)
def read(order_id: int) -> OrderOut:
    if order_id not in DB:
        raise HTTPException(404, "no such order")
    return DB[order_id]

@v1.patch("/{order_id}", responses=NOT_FOUND | CONFLICT)
def update(order_id: int, patch: OrderPatch) -> OrderOut:
    changes = patch.model_dump(exclude_unset=True)           # only what the client actually sent
    DB[order_id].update(changes)
    return DB[order_id]

@v1.get("/{order_id}/legacy-status", deprecated=True)
def legacy_status(order_id: int) -> dict:
    return {"status": "paid"}

app.include_router(v1)

with TestClient(app) as client:
    r = client.post("/v1/orders", json={"customerEmail": "[email protected]", "giftMessage": "Happy birthday!"})
    print(r.status_code, "Location:", r.headers["location"])
    print("wire format:", r.json())
    client.patch("/v1/orders/1", json={"deliverySlot": "evening"})     # giftMessage not sent: unchanged
    print("after PATCH:", {k: v for k, v in client.get("/v1/orders/1").json().items() if k != "createdAt"})
    client.patch("/v1/orders/1", json={"giftMessage": None})           # sent as null: cleared
    print("cleared gift:", client.get("/v1/orders/1").json()["giftMessage"])

spec = app.openapi()
print("\noperation ids:", [op["operationId"] for path in spec["paths"].values() for op in path.values()])
post = spec["paths"]["/v1/orders"]["post"]
patch = spec["paths"]["/v1/orders/{order_id}"]["patch"]
print("documented responses: POST", sorted(post["responses"]), " PATCH", sorted(patch["responses"]))
print("PATCH 409 body schema:", patch["responses"]["409"]["content"]["application/json"]["schema"])
print("request example names:", list(post["requestBody"]["content"]["application/json"]["examples"]))
print("OrderOut fields:", list(spec["components"]["schemas"]["OrderOut"]["properties"]))
print("deprecated:", [p for p, ops in spec["paths"].items() if any(o.get("deprecated") for o in ops.values())])

Output

201 Location: /v1/orders/1
wire format: {'id': 1, 'customerEmail': '[email protected]', 'giftMessage': 'Happy birthday!', 'deliverySlot': 'morning', 'createdAt': '2026-09-19T00:00:00Z'}
after PATCH: {'id': 1, 'customerEmail': '[email protected]', 'giftMessage': 'Happy birthday!', 'deliverySlot': 'evening'}
cleared gift: None

operation ids: ['orders-create', 'orders-read', 'orders-update', 'orders-legacy_status']
documented responses: POST ['201', '422']  PATCH ['200', '404', '409', '422']
PATCH 409 body schema: {'$ref': '#/components/schemas/Problem'}
request example names: ['gift']
OrderOut fields: ['id', 'customerEmail', 'giftMessage', 'deliverySlot', 'createdAt']
deprecated: ['/v1/orders/{order_id}/legacy-status']

PATCH done right

exclude_unset=True separates "not sent" (leave alone) from "sent as null" (clear it). Using exclude_none instead makes it impossible to clear a field, a very common bug.

Operation ids are an API

Client generators (openapi-generator, orval, openapi-ts, and others) turn operation ids into method names. The default ids are long and change when you rename a function. A custom generate_unique_id_function gives stable names like orders-create.

3. Versioning and evolution

ChangeBreaking?
add an optional request field, a new endpoint, or a new response fieldno (if clients ignore unknown fields, which you should document)
add a required request field, rename or remove a field, change a typeyes
add a value to an enum in a responseoften yes: strict clients (mobile apps, generated code) crash on unknown values
change a status code, error shape, pagination or default sortyes
make validation stricteryes, for everyone who sent the now-rejected values
  • URL versioning (/v1, /v2) is the most common and the easiest to route, cache and see in logs. Each version is its own APIRouter, sharing services underneath.
  • Evolve inside a version with additive changes only. Introduce /v2 only for a real break, and run both in parallel.
  • Deprecate loudly: deprecated=True in OpenAPI, plus Deprecation and Sunset response headers (RFC 9745 and RFC 8594), plus monitoring of who still calls the old endpoint before removing it.
  • Pin the contract in CI: diff the generated openapi.json against the last release (for example with oasdiff) and fail the build on breaking changes.

4. Docs in production

/docs and /openapi.json describe every endpoint, which helps attackers as much as partners. For internal or partner APIs, either switch them off or put them behind authentication:

docs off by default, served only to admins

from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.testclient import TestClient

app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)       # nothing public

def admin_only(x_api_key: Annotated[str | None, Header()] = None):
    if x_api_key != "admin-key-from-settings":
        raise HTTPException(404)                                     # don't even admit the docs exist

@app.get("/v1/ping")
def ping():
    return {"ok": True}

@app.get("/internal/openapi.json", include_in_schema=False, dependencies=[Depends(admin_only)])
def openapi():
    from fastapi.openapi.utils import get_openapi
    return get_openapi(title="Orders API", version="1.4.0", routes=app.routes)

@app.get("/internal/docs", include_in_schema=False, dependencies=[Depends(admin_only)])
def docs():
    return get_swagger_ui_html(openapi_url="/internal/openapi.json", title="Orders API docs")

with TestClient(app) as c:
    print("/docs              ", c.get("/docs").status_code)
    print("/openapi.json      ", c.get("/openapi.json").status_code)
    print("/internal/docs     ", c.get("/internal/docs").status_code, "(no key)")
    print("/internal/docs     ", c.get("/internal/docs", headers={"X-API-Key": "admin-key-from-settings"}).status_code, "(admin)")
    print("paths in the spec: ", list(c.get("/internal/openapi.json", headers={"X-API-Key": "admin-key-from-settings"}).json()["paths"]))

Output

/docs               404
/openapi.json       404
/internal/docs      404 (no key)
/internal/docs      200 (admin)
paths in the spec:  ['/v1/ping']
Hiding docs is not security

Removing /docs just makes attackers guess; every endpoint must still enforce its own authentication and authorization (part 18). Also check that include_in_schema=False routes are protected too: hidden is not the same as private.

5. Details that separate good APIs from great ones

Consistent envelopes

Lists return {"items": [...], "nextCursor": …}, everywhere. Errors are always RFC 9457 problems. Clients write one parser.

Money and time

Money as integer minor units (amountCents) or decimal strings, never floats. Times in UTC ISO 8601 with an offset.

Webhooks in the contract

@app.webhooks.post("order-paid") documents outgoing webhook payloads in the same OpenAPI document (OpenAPI 3.1).

Separate models per direction

OrderIn, OrderPatch, OrderOut. One shared model always ends up accepting fields clients must not set (part 18, mass assignment).

Recap

  • Resources and status codes: 201 + Location, 204, 202 for jobs, 404 for "not yours", sub-resources for actions.
  • PATCH with exclude_unset; separate input, patch and output models; camelCase via an alias generator.
  • Document everything: error models in responses, examples, tags, stable operation ids.
  • Evolve additively, version for real breaks, deprecate with headers and data, and diff the spec in CI.

Get the free RAG production checklist

25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries