In this post
What this post covers
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.
1. Resources, verbs and status codes
| Operation | Request | Success | Notes |
|---|---|---|---|
| list | GET /v1/orders?status=paid&limit=50&cursor=… | 200 | always paginate (part 16); filters are query parameters |
| create | POST /v1/orders | 201 + Location | accept an Idempotency-Key |
| read | GET /v1/orders/{id} | 200 + ETag | 404 for missing and for "not yours" |
| partial update | PATCH /v1/orders/{id} | 200 | only fields that were sent change (exclude_unset); If-Match |
| replace | PUT /v1/orders/{id} | 200 | the full representation; rarely needed |
| delete | DELETE /v1/orders/{id} | 204 | repeat deletes should also succeed (204 or 404, documented) |
| action | POST /v1/orders/{id}/cancel | 200 | state transitions are clearer as sub-resources than as PATCH {"status": …} |
| long job | POST /v1/reports | 202 + 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
| Change | Breaking? |
|---|---|
| add an optional request field, a new endpoint, or a new response field | no (if clients ignore unknown fields, which you should document) |
| add a required request field, rename or remove a field, change a type | yes |
| add a value to an enum in a response | often yes: strict clients (mobile apps, generated code) crash on unknown values |
| change a status code, error shape, pagination or default sort | yes |
| make validation stricter | yes, 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 ownAPIRouter, sharing services underneath. - Evolve inside a version with additive changes only. Introduce
/v2only for a real break, and run both in parallel. - Deprecate loudly:
deprecated=Truein OpenAPI, plusDeprecationandSunsetresponse 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.jsonagainst the last release (for example withoasdiff) 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']
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.