A path operation is the combination of an HTTP method and a URL path bound to a Python function via a decorator. FastAPI registers it as a route and auto-generates the OpenAPI schema entry for it.
@app.get("/items") # GET /items → list_items
async def list_items():
return [{"id": 1}]
@app.post("/items") # POST /items → create_item
async def create_item(item: Item):
return item
Each decorator (@app.get, @app.post, @app.put, @app.patch,
@app.delete, @app.head, @app.options, @app.trace) corresponds to
an HTTP method. The same path can be registered for multiple methods.
Rule of thumb: one decorator = one path operation = one OpenAPI endpoint entry.
FastAPI mirrors standard HTTP methods:
| Decorator | Method | Typical use |
|---|---|---|
@app.get |
GET | Read resource |
@app.post |
POST | Create resource |
@app.put |
PUT | Full update/replace |
@app.patch |
PATCH | Partial update |
@app.delete |
DELETE | Remove resource |
@app.head |
HEAD | Like GET but no body |
@app.options |
OPTIONS | CORS preflight / introspection |
@app.trace |
TRACE | Diagnostic loopback |
@app.patch("/items/{id}")
async def update_partial(id: int, patch: ItemPatch):
...
Rule of thumb: use PUT for full replacement, PATCH for partial updates — FastAPI doesn't enforce semantics, but your OpenAPI schema and clients will reflect the choice.
The operation_id is a unique string identifier for an OpenAPI operation.
Code generators (openapi-generator, orval) use it as the function name in
generated clients. FastAPI auto-generates it from the handler function name
and the path, but you can override it.
@app.get(
"/items/{item_id}",
operation_id="retrieve_item", # generated client function: retrieve_item()
)
async def get_item(item_id: int):
...
Without an explicit operation_id, FastAPI generates something like
get_item_items__item_id__get — verbose and brittle across renames.
Rule of thumb: set explicit operation_id values for any endpoint consumed by
generated client code to keep client method names stable across refactors.
Pass deprecated=True to the route decorator. FastAPI adds the OpenAPI
deprecated: true flag, which Swagger UI displays with a strikethrough.
@app.get("/old-endpoint", deprecated=True)
async def old_endpoint():
return RedirectResponse("/new-endpoint", status_code=301)
Deprecating in OpenAPI doesn't prevent the route from being called — you still
need to keep the handler working. For hard removal, emit a Deprecation header
with a sunset date, then delete the route in a future release.
Rule of thumb: always mark deprecated routes in OpenAPI before removing them so generated clients surface the warning during their build step.
Use responses dict in the route decorator to document non-default status codes.
The summary and description parameters document the operation itself.
@app.get(
"/items/{id}",
summary="Retrieve an item",
description="Returns the full item record. Returns 404 if the item does not exist.",
responses={
404: {"description": "Item not found"},
200: {"description": "The item", "model": Item},
},
)
async def get_item(id: int):
...
FastAPI also reads the handler's docstring as the operation description:
@app.get("/items/{id}")
async def get_item(id: int):
"""Return a single item by its numeric ID."""
...
Rule of thumb: use the docstring for prose descriptions; use the responses dict
to document error codes and their schemas.
Use the responses parameter with a dict keyed by status code. For typed bodies
pass "model": PydanticModel.
from fastapi.responses import JSONResponse
from typing import Union
class Item(BaseModel): id: int; name: str
class Message(BaseModel): message: str
@app.get(
"/items/{id}",
response_model=Item, # 200 default
responses={404: {"model": Message}}, # 404 documented
)
async def get_item(id: int) -> Union[Item, JSONResponse]:
item = await db.get(id)
if not item:
return JSONResponse(
status_code=404,
content={"message": "not found"},
)
return item
Note: FastAPI only validates the response_model; additional entries in
responses are documentation-only.
Rule of thumb: document all realistic non-200 responses in responses={} even if
FastAPI won't validate them — clients and SDK generators rely on the schema.
FastAPI matches routes in the order they are registered. The first matching
route wins. If a fixed path like /items/export is registered after a
parameterised path like /items/{id}, the {id} route captures the string
"export" as the value of id.
# WRONG order — /items/export matches the first route with id="export"
@app.get("/items/{id}")
async def get_item(id: int): ... # FastAPI raises 422 because "export" is not int
@app.get("/items/export")
async def export_items(): ... # never reached
# CORRECT order — fixed paths before parameterised
@app.get("/items/export")
async def export_items(): ...
@app.get("/items/{id}")
async def get_item(id: int): ...
Rule of thumb: register fixed/static paths before parameterised ones at the same
depth — or use type constraints ({id:int}) to let FastAPI discriminate automatically.
FastAPI uses Starlette's path converters in the URL string to constrain path parameters without Pydantic at the routing layer:
@app.get("/items/{item_id:int}") # only matches if segment is an integer
async def get_item(item_id: int): ...
@app.get("/files/{file_path:path}") # matches any path, including slashes
async def get_file(file_path: str): ...
Converters: str (default), int, float, uuid, path.
Combining with Pydantic type annotations provides two-level validation: Starlette rejects non-int URLs at routing time; Pydantic further validates range or constraints in the handler.
Rule of thumb: use :int or :uuid converters in paths to prevent wrong-type
segments from even reaching the handler — cleaner 404 instead of 422.
APIRouter is FastAPI's mechanism for splitting route definitions across multiple
files. Routes are registered on the router, then the router is mounted onto the
app (or another router) with a prefix and shared config.
# routers/users.py
from fastapi import APIRouter
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/")
async def list_users(): ...
@router.get("/{id}")
async def get_user(id: int): ...
# main.py
from routers import users
app.include_router(users.router)
# registers: GET /users/ and GET /users/{id}
Benefits: logical grouping, reusable prefix/tags/dependencies, testable in isolation, avoids a 1000-line main.py.
Rule of thumb: one APIRouter per resource or domain concept; mount all routers
in main.py.
Pass dependencies=[Depends(fn)] to APIRouter() or include_router(). Every
route in that router runs the dependency before the handler — useful for auth or
rate-limiting.
from fastapi import APIRouter, Depends
from .auth import verify_token
# Option A: on the router itself
router = APIRouter(
prefix="/admin",
dependencies=[Depends(verify_token)],
)
# Option B: at mount time (non-destructive)
app.include_router(router, dependencies=[Depends(verify_token)])
Both options stack — if you set deps in both places, both run. Router-level deps run before route-level deps.
Rule of thumb: put auth/rate-limiting deps at the router level so you can't accidentally forget them on a new route; put business-logic deps at the route level.
FastAPI uses the return type annotation in two ways:
- OpenAPI schema — the annotation becomes the documented response schema.
- Response validation — if
response_modelis also set, it overrides; if not, FastAPI uses the return annotation as the implicitresponse_model.
class UserOut(BaseModel):
id: int
name: str
@app.get("/users/{id}")
async def get_user(id: int) -> UserOut: # annotation drives the schema
return await db.get(User, id)
Since Pydantic v2 + FastAPI 0.100+, you can return an ORM object that matches
UserOut and FastAPI will serialise it. Using response_model=UserOut in the
decorator is equivalent but takes precedence.
Rule of thumb: use return type annotations as the primary way to document response schemas — it's more Pythonic and keeps the decorator line shorter.
Set status_code=204 in the decorator and return None (or nothing). FastAPI
sends no body for 204 responses.
from fastapi import status
from fastapi.responses import Response
@app.delete("/items/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_item(id: int):
await db.delete(id)
# implicit return None → no body sent
Alternatively, return Response(status_code=204) explicitly when you need to
avoid FastAPI trying to serialise None.
Rule of thumb: always use 204 for successful DELETE operations that return no body — clients rely on the status code to know there's nothing to parse.
Use @app.websocket("/path") and declare the handler with a WebSocket
parameter. The handler stays alive in a loop, sending and receiving messages
until the connection closes.
from fastapi import WebSocket
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
try:
while True:
data = await ws.receive_text()
await ws.send_text(f"Echo: {data}")
except WebSocketDisconnect:
pass # client closed the connection
WebSocket handlers can use Depends() just like HTTP handlers, making it easy
to authenticate the upgrade request before ws.accept().
Rule of thumb: always call await ws.accept() before sending; catch
WebSocketDisconnect to clean up resources when the client leaves.
Mount Starlette's StaticFiles on a path:
from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static")
Requests to /static/logo.png serve ./static/logo.png directly without going
through FastAPI's routing. It's handled at the ASGI mount level, so it bypasses
middleware that is added after the mount.
For a single-page app (SPA) fallback:
app.mount("/", StaticFiles(directory="dist", html=True), name="spa")
html=True serves index.html for any path not found in the directory.
Rule of thumb: use StaticFiles for assets; for dynamically generated file
downloads use FileResponse inside a regular route handler.
More Fundamentals interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.