- Uvicorn accepts the TCP connection and parses HTTP/1.1 or HTTP/2 bytes
into an ASGI
scope + receive/sendpair. - Middleware stack (outermost first) wraps the request. Each middleware can
short-circuit or modify
requestbefore passing to the next layer. - FastAPI router matches the URL path + HTTP method to a route handler.
If no match → 404
HTTPException. - Dependency injection resolves all
Depends()in the handler signature, recursively, before the handler runs. - Parameter extraction & Pydantic validation — path params, query params,
headers, cookies and the request body are parsed and validated against type
annotations. Validation error → 422
RequestValidationError. - Handler executes —
async defon the event loop,defin a thread pool. - Response serialization — the return value is filtered through
response_model(if set), then serialized to JSON via Pydantic. - Middleware stack (innermost first on the way out) can modify the response.
- Uvicorn writes the HTTP response bytes to the socket.
Rule of thumb: middleware wraps everything; dependency injection happens before validation; validation happens before the handler body.
Middleware added with app.add_middleware() wraps the app from the outside in:
the last middleware added becomes the outermost layer. For requests, outermost
runs first; for responses, innermost runs first (onion model).
app.add_middleware(TimingMiddleware) # added first → innermost layer
app.add_middleware(AuthMiddleware) # added second → outermost layer
# Request flow: AuthMiddleware → TimingMiddleware → route handler
# Response flow: route handler → TimingMiddleware → AuthMiddleware
This mirrors how ASGI middleware chains work in Starlette. FastAPI also applies its own built-in error handler and routing logic inside the inner layers.
Rule of thumb: add logging/timing middleware last so it wraps everything, including auth middleware.
HTTPException is FastAPI's way to abort a request with a specific HTTP status
code and detail message. Raising it anywhere in a handler or dependency
immediately skips the rest of the stack and returns a JSON error response.
from fastapi import HTTPException
@app.get("/items/{item_id}")
async def get_item(item_id: int):
item = await db.get(item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return item
# Response: {"detail": "Item not found"} with HTTP 404
You can add custom headers for auth challenges:
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
Rule of thumb: raise HTTPException for expected error conditions
(not found, unauthorized); let unhandled exceptions propagate to the
global exception handler for unexpected errors.
Use @app.exception_handler(ExcType) to register a handler that catches a
specific exception class anywhere in the request chain.
from fastapi import Request
from fastapi.responses import JSONResponse
class InsufficientFundsError(Exception):
def __init__(self, balance: float):
self.balance = balance
@app.exception_handler(InsufficientFundsError)
async def funds_handler(request: Request, exc: InsufficientFundsError):
return JSONResponse(
status_code=402,
content={"detail": f"Balance too low: {exc.balance}"},
)
To override the default validation error handler:
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
return JSONResponse(status_code=422, content={"errors": exc.errors()})
Rule of thumb: use HTTPException for expected HTTP errors; use custom exception
handlers to convert domain exceptions into HTTP responses without polluting handlers.
FastAPI returns HTTP 422 Unprocessable Entity with a JSON body that lists every field that failed validation. This is automatic — you don't write any validation code yourself.
{
"detail": [
{
"type": "missing",
"loc": ["body", "email"],
"msg": "Field required",
"input": {"name": "Alice"},
"url": "https://errors.pydantic.dev/2.0/v/missing"
}
]
}
The loc array pinpoints where the bad value came from: ["body", "field"]
for body params, ["query", "name"] for query params, ["path", "id"] for
path params.
Rule of thumb: 422 = you sent bad data; 400 = request was intentionally rejected by application logic; treat them differently in client error handling.
FastAPI builds a dependency graph at startup by inspecting type annotations of all handlers. At request time it resolves depth-first: sub-dependencies are resolved before the dependencies that need them.
Shared dependencies are called once per request (by default). If two
dependencies both declare Depends(get_db), FastAPI calls get_db() once and
passes the same object to both.
async def get_db(): # called once even if used by two deps
async with AsyncSession() as session:
yield session
async def get_current_user(db = Depends(get_db)):
...
async def check_permission(db = Depends(get_db)):
...
@app.get("/secure")
async def handler(
user = Depends(get_current_user),
perm = Depends(check_permission), # same db session as above
):
...
To opt out of caching (force a fresh call), pass use_cache=False:
Depends(get_db, use_cache=False).
Rule of thumb: shared sub-dependencies are singletons per request — rely on this to share a DB session cleanly without a thread-local or global.
- If a
response_modelis set, FastAPI uses Pydantic to validate and filter the return value through that model (strips extra fields, applies aliases, etc.). - The filtered Python object is converted to a JSON-compatible dict via
Pydantic's
model_dump(mode="json"). - The dict is serialized to JSON bytes using
orjson(if installed) or the standardjsonmodule. - The bytes are sent as
Content-Type: application/json.
class UserOut(BaseModel):
id: int
name: str
# no `password` field → never leaked
@app.get("/users/{id}", response_model=UserOut)
async def get_user(id: int) -> User:
return await db.get(User, id) # User has password field — filtered out
If no response_model is set, FastAPI calls jsonable_encoder() on the return
value, which handles datetime, UUID, Enum etc.
Rule of thumb: always set response_model on endpoints that return ORM objects
to prevent accidental data leakage.
BackgroundTasks run after the HTTP response has been sent to the client.
The route handler adds tasks; FastAPI sends the response; then the tasks execute
sequentially in the same event loop (for async tasks) or thread pool (for sync tasks).
from fastapi import BackgroundTasks
def send_email(to: str, msg: str):
... # slow SMTP call — runs after response is sent
@app.post("/signup")
async def signup(email: str, background_tasks: BackgroundTasks):
await db.create_user(email)
background_tasks.add_task(send_email, email, "Welcome!")
return {"status": "created"} # response sent immediately
Because they run in the same process, background tasks share memory with the app but have no access to the request object once it's closed.
Rule of thumb: use BackgroundTasks for quick fire-and-forget work (email,
analytics); use a dedicated task queue (Celery, ARQ) for retryable or long-running jobs.
jsonable_encoder converts Python objects that aren't natively JSON-serialisable
(datetime, UUID, Decimal, Pydantic models, ORM objects) into JSON-compatible
Python primitives (str, int, dict, list).
from fastapi.encoders import jsonable_encoder
from datetime import datetime
obj = {"created": datetime(2026, 1, 1), "id": uuid4()}
safe = jsonable_encoder(obj)
# {"created": "2026-01-01T00:00:00", "id": "550e8400-..."}
FastAPI calls it automatically when serializing responses. You need to call it manually when:
- Storing something in a cache or NoSQL DB as JSON.
- Passing an object to a
JSONResponseconstructor directly.
return JSONResponse(content=jsonable_encoder(my_pydantic_model))
Rule of thumb: let FastAPI call jsonable_encoder implicitly through response_model;
call it explicitly only when building JSONResponse objects manually.
FastAPI uses the parameter name and type annotation as signals:
| Source | Signal |
|---|---|
| Path parameter | name appears in the route path string /{name} |
| Query parameter | simple type (str, int, float, bool, Optional) not in path |
| Request body | Pydantic BaseModel subclass (or annotated with Body()) |
| Header | annotated with Header() |
| Cookie | annotated with Cookie() |
@app.put("/items/{item_id}")
async def update_item(
item_id: int, # path — in "{item_id}"
q: str | None = None, # query — simple type, not in path
item: Item, # body — Pydantic model
):
...
Rule of thumb: if the name matches the path template, it's a path param; if it's a Pydantic model it's a body; everything else defaults to query.
Pass status_code to the route decorator. FastAPI returns 200 by default for GET
and 200 for POST; 201 is the semantic choice for resource creation.
from fastapi import status
@app.post("/items", status_code=status.HTTP_201_CREATED)
async def create_item(item: Item):
saved = await db.save(item)
return saved
To set the status code dynamically inside the handler, inject the Response object:
from fastapi import Response
@app.get("/maybe")
async def maybe(response: Response, id: int):
item = await db.get(id)
if not item:
response.status_code = 404
return None
return item
Rule of thumb: set status codes at the decorator level for fixed codes;
inject Response only when the code depends on runtime logic.
prefix in include_router is a path segment prepended to all routes in
that router. The route decorator path is the specific endpoint path within the
router. They concatenate.
router = APIRouter()
@router.get("/items") # partial path
async def list_items(): ...
@router.get("/items/{id}") # partial path
async def get_item(id: int): ...
app.include_router(router, prefix="/v1")
# registered as: GET /v1/items and GET /v1/items/{id}
Setting the full path in the decorator makes the router useless as a module
prefix. Putting everything in prefix lets you re-mount the same router at
different prefixes (e.g., /v1 and /v2).
Rule of thumb: keep route decorators as relative sub-paths; use prefix in
include_router for versioning or top-level resource grouping.
No. By default FastAPI treats trailing slashes as distinct routes and does
not redirect. A GET to /items/ returns 404 if only /items is registered.
@app.get("/items") # only matches /items
async def list_items(): ...
Options:
- Register both:
@app.get("/items")and@app.get("/items/"). - Use the
redirect_slashes=Falseorredirect_slashes=Trueflag onFastAPI():app = FastAPI(redirect_slashes=True)— a trailing-slash request is 307-redirected to the non-slash version.
app = FastAPI(redirect_slashes=True)
Rule of thumb: disable automatic trailing-slash handling (redirect_slashes=False)
for strict APIs; enable it (True) for user-facing endpoints where clients vary.
FastAPI generates the OpenAPI schema at import/startup time, not per-request.
It inspects route decorators, type annotations and Pydantic models once and caches
the result. The /openapi.json endpoint just serves this cached dict.
To disable the docs entirely (e.g., in production):
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
To move the schema to a non-default path:
app = FastAPI(openapi_url="/api/v1/openapi.json")
Lazy generation (compute only on first request) can be achieved by subclassing:
class LazyFastAPI(FastAPI):
def openapi(self):
if not self.openapi_schema:
self.openapi_schema = get_openapi(...)
return self.openapi_schema
Rule of thumb: disable openapi_url in production if you don't want to expose
your API schema publicly; keep it enabled in staging for team tooling.
More Fundamentals interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.