The 60 FastAPI questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced backend rounds.
60 questions with answersKey Takeaways
FastAPI is a Python web framework for building APIs, created by Sebastian Ramirez and first released in 2018. It sits on two libraries: Starlette handles the ASGI web layer (routing, requests, async support) and Pydantic handles data validation and serialization. The design idea is simple: you write standard Python type hints, and FastAPI turns them into request validation, response models, and interactive OpenAPI documentation with no extra code. According to the official FastAPI documentation, that type-hint-driven approach cuts bugs and speeds development while giving you editor autocompletion for free. In interviews, FastAPI questions probe the parts that aren't obvious from a tutorial: when async actually helps, how the dependency injection system works, how Pydantic validates and coerces data, and the gotchas around sync code blocking the event loop. This page collects the 60 questions that come up most, each with a direct answer and runnable code. Increasingly the first backend round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: FastAPI Course for Beginners
Video: FastAPI Course for Beginners (freeCodeCamp.org, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your FastAPI certificate.
The fundamentals every entry-level backend round checks. If any answer here surprises you, that's your study list.
FastAPI is a modern Python framework for building APIs. It's built on Starlette for the async web layer and Pydantic for data validation, and it uses standard Python type hints to validate requests, serialize responses, and generate interactive docs.
The problem it solves: in older frameworks you wrote validation, serialization, and API docs by hand. FastAPI derives all three from your type hints, so you write less code, get fewer bugs, and get editor autocompletion for free.
Key point: A one-line definition plus the type-hints-drive-everything idea is the answer the question needs. Naming Starlette and Pydantic shows you know what's underneath.
Create an app instance with FastAPI(), decorate a function with a path operation like @app.get, return a dict, and run it with an ASGI server such as uvicorn. That's the whole minimal app: a few lines gives you a working JSON endpoint.
FastAPI serializes the returned dict to JSON automatically. The interactive docs appear at /docs with zero extra work.
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "hello"}
# run: uvicorn main:app --reloadKey point: Interviewers open with this to see if you can wire up a route from memory. Mention uvicorn and the auto docs to sound like you've actually run it.
A path parameter is part of the URL path, like /items/{item_id}, and identifies a specific resource. A query parameter comes after the ? in the URL, like /items?skip=0&limit=10, and usually filters, paginates, or sorts.
FastAPI decides which is which by name: if a function parameter's name appears in the route path, it's a path parameter, otherwise it's a query parameter.
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
# item_id is a path param (it is in the path)
# q is a query param with a default, so it is optional
return {"item_id": item_id, "q": q}| Path parameter | Query parameter | |
|---|---|---|
| Position | In the URL path | After ? in the URL |
| Detected by | Name is in the route path | Name is not in the path |
| Typical use | Identify one resource | Filter, sort, paginate |
| Required? | Yes, by default | Optional if it has a default |
Key point: The follow-up is 'how does FastAPI know which is which?'. the technical answer is the name-in-the-path rule. Have it ready.
A Pydantic model is a class that declares fields and their types. FastAPI uses it to validate incoming request bodies and to shape outgoing responses, so the types you write become the rules the API enforces at the edge, before any of your logic runs.
When you annotate a request parameter with a Pydantic model, FastAPI reads the JSON body, validates each field against the model, coerces types where it can, and hands you a typed object. If validation fails, it returns a 422 with a clear error list before your code ever runs.
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
in_stock: bool = True
@app.post("/items")
def create_item(item: Item):
return item # validated and serialized for youKey point: Interviewers use this to check you understand that validation is declarative. The automatic 422 on bad input matters.
You declare a parameter whose type is a Pydantic model. FastAPI reads the request body as JSON, validates it against the model, coerces types where it safely can, and hands you a typed instance. If validation fails, it returns a 422 before your code runs.
You don't parse the body yourself or call request.json(). The type annotation is the instruction: this parameter comes from the JSON body.
Key point: The trap is candidates reaching for the raw Request object. Show that the model annotation is all you need for the common case.
Use async def when the route awaits async I/O: an async database driver, an async HTTP client, or anything you call with await. Use plain def when the work is synchronous, because FastAPI runs def routes in a threadpool so they don't block the event loop.
The danger zone is putting blocking code inside an async def route. That runs directly on the event loop and stalls every other request. If your libraries aren't async, plain def is the safer choice.
# async: awaits async I/O
@app.get("/async")
async def get_async():
data = await fetch_from_api()
return data
# sync: FastAPI runs this in a threadpool
@app.get("/sync")
def get_sync():
return blocking_db_call()Key point: The follow-up is always 'what if you put a blocking call in an async route?'. Answer: it blocks the event loop. That's the whole point of this question.
Watch a deeper explanation
Video: How to Use FastAPI: A Detailed Python Tutorial (ArjanCodes, YouTube)
FastAPI builds an OpenAPI schema from your routes, type hints, and Pydantic models, then serves two interactive UIs from it: Swagger UI at /docs and ReDoc at /redoc. Every path, parameter, and model you declare shows up automatically, with no separate documentation step.
You write nothing extra. Every path, parameter, request model, and response model you declare shows up in the docs, and you can try endpoints straight from the browser.
Key point: both /docs and /redoc, and that they come from the OpenAPI schema, matters.
They map an HTTP method and a URL path to a function. @app.get('/users') handles GET requests to /users, @app.post('/users') handles POST, and there are matching decorators for put, delete, and patch. The decorated function becomes the handler FastAPI calls for that method and path.
The decorated function is the handler: FastAPI calls it, passes the validated parameters, and serializes what it returns to JSON.
@app.get("/users")
def list_users():
return [{"id": 1}]
@app.post("/users")
def create_user(user: dict):
return {"created": user}response_model declares the Pydantic model FastAPI should use to shape and validate the response. You pass it in the decorator, like @app.get('/users/{id}', response_model=UserOut), and FastAPI serializes whatever your function returns through that model before it reaches the client.
The real value is filtering: if your internal model has a password hash, a separate UserOut response model guarantees it never leaks to the client, because FastAPI serializes only the fields the response model declares.
class UserIn(BaseModel):
name: str
password: str
class UserOut(BaseModel):
name: str # no password field
@app.post("/users", response_model=UserOut)
def create(user: UserIn):
return user # password is stripped from the responseKey point: The password-stripping example is the answer interviewers hope to hear for 'why not just return the object?'.
For the default success code, pass status_code in the decorator, like @app.post('/items', status_code=201). For errors, raise HTTPException with the status_code and a detail message, and FastAPI builds the JSON error response for you with the right status and body.
Returning a dict with an 'error' key does not change the status; it still sends 200. HTTPException is how you send a real 4xx or 5xx.
from fastapi import HTTPException, status
@app.get("/items/{id}", status_code=200)
def get_item(id: int):
item = db.get(id)
if item is None:
raise HTTPException(status_code=404, detail="Not found")
return itemHTTPException is FastAPI's way to return an error response from anywhere in your code. You raise it with a status_code, a detail message, and optional headers, and FastAPI turns it into a JSON error with the correct status.
It short-circuits the request: nothing after the raise runs, and the client gets a clean, structured error.
from fastapi import HTTPException
if not user.is_admin:
raise HTTPException(
status_code=403,
detail="Admins only",
headers={"X-Error": "forbidden"},
)Use Query from fastapi as the default value. Query lets you set constraints like min_length, max_length, ge, le, and a description, and mark the parameter required by giving it no default or the Ellipsis.
FastAPI enforces these before your function runs and reports failures as a 422.
from fastapi import Query
@app.get("/search")
def search(q: str = Query(min_length=3, max_length=50)):
return {"q": q}Key point: Interviewers like when you know constraints live in Query (and Path for path params), not in hand-written if-checks.
Give the parameter a default value in the function signature. A query parameter with a default is optional, and one without a default is required, so FastAPI returns a 422 if the client leaves a required parameter out.
For optional values that can also be missing entirely, use a union with None and default to None, like q: str | None = None.
@app.get("/items")
def list_items(skip: int = 0, limit: int = 10, q: str | None = None):
return {"skip": skip, "limit": limit, "q": q}A route can return a dict, a list, a Pydantic model, a plain value, or a Response object. FastAPI runs the result through its JSON encoder, which knows how to serialize Pydantic models, datetimes, UUIDs, and more.
For full control over status, headers, or streaming, return a Response subclass like JSONResponse or StreamingResponse directly.
Key point: Naming jsonable_encoder or the fact that Pydantic models serialize automatically shows you understand the serialization step, not just that 'it returns JSON'.
Pydantic checks each field against its declared type. Where it safely can, it coerces: the string '10' for an int field becomes 10. Where it can't, like 'abc' for an int, it raises a validation error.
You get typed, clean data inside your handler, and the client gets a precise 422 telling them which field was wrong.
class Order(BaseModel):
quantity: int
price: float
# {"quantity": "3", "price": "9.99"} -> quantity=3, price=9.99
# {"quantity": "abc"} -> 422 error for the quantity fielduvicorn is an ASGI server. FastAPI is an ASGI application: it defines how to handle requests but doesn't listen on a socket itself. uvicorn is the process that binds to a port and drives the app.
In production you often run uvicorn workers behind a process manager or Gunicorn, and behind a reverse proxy like nginx.
Key point: The distinction that framework and server are separate (ASGI app vs ASGI server) is what this question checks.
WSGI is the older synchronous Python web server interface used by Flask and Django's traditional stack. ASGI is the async successor: it supports async handlers, WebSockets, and long-lived connections that WSGI's one-request-per-thread model can't express. FastAPI targets ASGI, which is why async comes built in.
FastAPI is ASGI, which is why it can run async routes and WebSockets natively. That's the technical reason it can do async where a plain WSGI framework can't.
| WSGI | ASGI | |
|---|---|---|
| Model | Synchronous | Asynchronous |
| WebSockets | No | Yes |
| Used by | Flask, classic Django | FastAPI, Starlette |
| Server example | Gunicorn, uWSGI | Uvicorn, Hypercorn |
Most of the time you don't: declaring typed parameters and Pydantic models is the FastAPI way. You reach for the Request object when you need something outside the declared parameters, like raw headers, the client IP, cookies you didn't declare, or the unparsed body.
Add request: Request to your signature and FastAPI injects it.
from fastapi import Request
@app.get("/info")
def info(request: Request):
return {"client": request.client.host, "agent": request.headers.get("user-agent")}For form fields use Form, and for uploads use UploadFile with File. UploadFile streams large files to a spooled temporary location instead of loading them fully into memory, and it gives you an async read interface so big uploads don't exhaust RAM under load.
Form and JSON body can't be mixed in the same request, since they use different content types, so pick one per endpoint.
from fastapi import Form, File, UploadFile
@app.post("/upload")
async def upload(note: str = Form(), file: UploadFile = File()):
content = await file.read()
return {"note": note, "size": len(content)}PUT replaces a resource with the full new representation, so the client sends every field. PATCH applies a partial update, so the client sends only the fields that change. In FastAPI you handle a partial update by reading the incoming model with exclude_unset so absent fields don't overwrite stored values.
The pattern is: load the stored item, call the update model's model_dump(exclude_unset=True), and merge only the provided keys.
@app.patch("/items/{id}")
def patch_item(id: int, update: ItemUpdate):
stored = db.get(id)
changes = update.model_dump(exclude_unset=True)
updated = stored.model_copy(update=changes)
db.save(updated)
return updatedKey point: The exclude_unset detail is what the key signal is. Without it, a PATCH silently wipes fields the client did not send.
For candidates with working experience: dependency injection, Pydantic depth, testing, and the async judgment that separates users from understanders.
You write a function (or callable) that returns what a route needs, then declare it as a parameter with Depends. FastAPI resolves it per request, runs it, and injects the result into your route.
Dependencies are the reuse mechanism: a database session, the current authenticated user, shared pagination params, all written once and pulled into any route. They can depend on other dependencies, and they make testing easy because you can override them.
from fastapi import Depends
def pagination(skip: int = 0, limit: int = 10):
return {"skip": skip, "limit": limit}
@app.get("/items")
def list_items(page: dict = Depends(pagination)):
return pageKey point: Being able to write a dependency from memory, and mention overriding it in tests, is the bar. The follow-up is usually 'how do you inject a DB session?'.
Watch a deeper explanation
Video: Intro to FastAPI - The Best Way to Create APIs in Python? (Pretty Printed, YouTube)
A dependency can use yield instead of return to run cleanup code after the response. The code before yield is setup, the yielded value is injected, and the code after yield runs once the request finishes, even if it errored.
The classic use is a database session: open it before yield, close it after. It's FastAPI's equivalent of a per-request context manager.
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close() # runs after the response, always
@app.get("/users")
def read_users(db = Depends(get_db)):
return db.query(User).all()Key point: The try/finally around yield is the detail that shows you understand cleanup happens even on errors. the key signal is it.
Pydantic v2 rewrote the validation core in Rust, so it's much faster, and it renamed several APIs. Validators moved from @validator to @field_validator and @model_validator, Config class options moved to model_config, orm_mode became from_attributes, and .dict()/.json() became .model_dump()/.model_dump_json().
It matters because interviewers use it to gauge how current you are. Modern FastAPI ships on v2; knowing the renamed methods signals you've upgraded a real project.
| Concept | Pydantic v1 | Pydantic v2 |
|---|---|---|
| Field validator | @validator | @field_validator |
| Serialize to dict | .dict() | .model_dump() |
| ORM loading | orm_mode = True | from_attributes = True |
| Model config | class Config | model_config = ConfigDict(...) |
Key point: Even naming two or three of these changes indicates recent hands-on experience. Vague 'v2 is faster' answers do not.
Use @field_validator for a single field and @model_validator for cross-field checks. A field validator receives the value, runs your logic, and returns the cleaned value or raises ValueError, which FastAPI turns into a 422.
Field-level constraints (min_length, ge, pattern) go on Field; validators are for logic those constraints can't express.
from pydantic import BaseModel, field_validator
class Signup(BaseModel):
email: str
age: int
@field_validator("age")
@classmethod
def adult(cls, v):
if v < 18:
raise ValueError("must be 18 or older")
return vMiddleware wraps every request and response. You get the request before it hits any route and the response before it goes back to the client, so it's the place for cross-cutting concerns: logging, timing, adding headers, or catching everything.
Since FastAPI is built on Starlette, you write middleware the Starlette way, either as a function with @app.middleware('http') or a class.
import time
@app.middleware("http")
async def add_timing(request, call_next):
start = time.perf_counter()
response = await call_next(request)
response.headers["X-Process-Time"] = str(time.perf_counter() - start)
return responseAdd CORSMiddleware to the app and list the allowed origins, methods, and headers. Without it, a browser frontend served from a different origin gets its requests blocked by the same-origin policy, even though the API itself is working fine.
Be specific with allow_origins in production. Using a wildcard '*' together with credentials is not allowed by the CORS spec and browsers will reject it.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["*"],
allow_headers=["*"],
)Key point: The wildcard-plus-credentials gotcha is a favorite follow-up. Knowing it indicates real deployment experience.
BackgroundTasks run work after the response is sent, inside the same process. You add a task and return; FastAPI runs it once the client has its response. Good for quick fire-and-forget I/O like sending a confirmation email.
They are not a real task queue. There's no persistence, no retries, no separate workers, and heavy or CPU-bound work still ties up the process. For that, reach for Celery, RQ, or a message queue.
from fastapi import BackgroundTasks
@app.post("/signup")
def signup(email: str, tasks: BackgroundTasks):
tasks.add_task(send_welcome_email, email)
return {"status": "ok"} # email sends after this returnsKey point: The trap is treating BackgroundTasks as a queue. Naming the no-retry, no-persistence limits is what the question screens for.
Use TestClient (built on httpx) with pytest. TestClient wraps your app and lets you make requests in-process without starting a real server, so you assert on status codes and JSON bodies in fast, isolated tests that run anywhere pytest runs.
Override dependencies with app.dependency_overrides to swap a real database for a test one, which is the payoff of putting the DB session behind Depends in the first place.
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_read_root():
r = client.get("/")
assert r.status_code == 200
assert r.json() == {"message": "hello"}Key point: dependency_overrides matters. That link is what a strong answer shows.
TestClient runs async routes fine for most cases because it drives the event loop for you. When you need to await things directly in a test, use httpx.AsyncClient with an ASGI transport and an async test runner like pytest-asyncio.
The rule: TestClient for the common request-response assertions, AsyncClient when the test itself must be async.
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
@pytest.mark.asyncio
async def test_root():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get("/")
assert r.status_code == 200Wire the session through dependency injection. Create the engine and session factory once, then a get_db dependency that yields a session and closes it after the request. Inject that session into routes with Depends.
For sync ORMs like classic SQLAlchemy, use plain def routes so the blocking calls run in the threadpool. For async, use an async driver and SQLAlchemy's async session with async def routes.
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get("/users/{id}")
def get_user(id: int, db = Depends(get_db)):
user = db.get(User, id)
if not user:
raise HTTPException(404, "not found")
return userKey point: The sync-driver-with-def, async-driver-with-async-def rule is the detail that separates people who've shipped from people who've read a tutorial.
An async def route runs on the single-threaded event loop. A blocking call, a sync database driver, time.sleep, a heavy CPU loop, holds the loop and prevents it from serving any other request until it finishes. Throughput collapses under load.
The fixes: use an async library so you can await instead of block, use a plain def route so FastAPI runs it in a threadpool, or push blocking work off the loop with run_in_executor.
# BAD: blocks the whole event loop
@app.get("/bad")
async def bad():
time.sleep(5)
return {"done": True}
# OK: FastAPI runs def routes in a threadpool
@app.get("/ok")
def ok():
time.sleep(5)
return {"done": True}Key point: This is the single most-asked FastAPI gotcha. Explain the event loop and give both fixes to sound senior.
Watch a deeper explanation
Video: FastAPI Tutorial - Building RESTful APIs with Python (Amigoscode, YouTube)
Split routes into modules, each with its own APIRouter, then include them in the main app with app.include_router. You can set a shared prefix, tags, and dependencies per router, so an entire section inherits a URL prefix and its auth in one place instead of repeating it on every route.
This keeps a growing app organized: a users router, an items router, an auth router, each in its own file, all mounted in one place.
from fastapi import APIRouter
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/")
def list_users():
return []
# in main.py
app.include_router(router)Use the security utilities. OAuth2PasswordBearer gives you a dependency that extracts the bearer token from the Authorization header. Write a get_current_user dependency that decodes and verifies the token (commonly a JWT) and returns the user, or raises 401.
Because it's a dependency, you attach it to any route that needs a logged-in user, and FastAPI runs the auth check before your handler.
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
oauth2 = OAuth2PasswordBearer(tokenUrl="token")
def current_user(token: str = Depends(oauth2)):
user = decode_token(token)
if not user:
raise HTTPException(401, "invalid token")
return user
@app.get("/me")
def me(user = Depends(current_user)):
return userKey point: Framing auth as 'just another dependency' is the mental model the question needs. The JWT decode is the detail they probe next.
Use Pydantic Settings (pydantic-settings). Define a Settings model whose fields map to environment variables, and Pydantic reads, validates, and types them at startup, optionally loading from a .env file. A missing or malformed variable fails loudly at boot instead of surfacing as a mysterious runtime error later.
Inject settings as a dependency, often cached with lru_cache so it's parsed once. Secrets come from the environment or a secret manager, never the repo.
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
database_url: str
debug: bool = False
model_config = {"env_file": ".env"}
@lru_cache
def get_settings():
return Settings()Register a handler with @app.exception_handler for a specific exception type or a custom one. It receives the request and the exception and returns a Response, so you can produce a consistent error shape across the whole app.
This is how you turn a domain exception into a clean JSON error without try/except in every route.
from fastapi.responses import JSONResponse
class NotFoundError(Exception):
pass
@app.exception_handler(NotFoundError)
def handle_not_found(request, exc):
return JSONResponse(status_code=404, content={"detail": "resource missing"})Nest Pydantic models inside each other and use typed lists. A model field can be another model, or a list of models, and FastAPI validates the whole tree in one pass, reporting exactly which nested field failed if the incoming JSON doesn't match.
This is where declarative validation pays off: a deeply nested JSON body validates in one place, and the docs show the full structure.
class Item(BaseModel):
name: str
price: float
class Order(BaseModel):
customer: str
items: list[Item] # a list of nested models
@app.post("/orders")
def create_order(order: Order):
return {"count": len(order.items)}Set title, description, and version on the FastAPI() constructor, add tags and summaries per route, and use response_model and status_code so the docs reflect reality. For deep changes, override app.openapi() to edit the generated schema.
Well-documented routes make the generated /docs genuinely useful to consumers, which is part of why teams pick FastAPI.
Return a StreamingResponse wrapping a generator or async generator. FastAPI sends chunks as they're produced instead of building the whole body in memory, which is right for big files, CSV exports, or server-sent events.
Set the media_type so the client knows what it's receiving.
from fastapi.responses import StreamingResponse
def row_generator():
for row in query_rows():
yield f"{row}\n"
@app.get("/export")
def export():
return StreamingResponse(row_generator(), media_type="text/plain")Because FastAPI is ASGI, it supports WebSockets natively. You decorate a function with @app.websocket, accept the connection, then loop receiving and sending messages over a persistent, two-way channel. It's the base for chat, live dashboards, and push notifications where plain request-response HTTP falls short.
Unlike HTTP routes, a WebSocket handler is a long-lived connection, so you manage its lifecycle: accept, communicate, and handle disconnects.
from fastapi import WebSocket
@app.websocket("/ws")
async def ws(websocket: WebSocket):
await websocket.accept()
while True:
msg = await websocket.receive_text()
await websocket.send_text(f"echo: {msg}")Map HTTP methods to actions: GET reads, POST creates, PUT or PATCH updates, DELETE removes. The resources with nouns and plurals like /users and /users/{id}, return correct status codes, and keep GET requests side-effect-free so caches and retries stay safe.
FastAPI doesn't force REST, but the question expects you to design clean, predictable routes. Good status codes and resource naming are the visible signs of that.
Key point: Design questions often start here. Showing you pick status codes and route shapes deliberately reads better than 'I just return 200'.
Watch a deeper explanation
Video: What is a REST API? (IBM Technology, YouTube)
advanced rounds probe async internals, production scaling, and design judgment. Expect every answer here to draw a follow-up.
An ASGI server (uvicorn) runs an asyncio event loop. Async def routes execute as coroutines on that loop, interleaving at each await point over OS-level I/O readiness. Plain def routes are pushed to a threadpool so their blocking calls don't touch the loop.
Nothing runs in parallel within one worker; it's cooperative concurrency on one thread plus a threadpool for sync work. Real CPU parallelism comes from running multiple worker processes.
Key point: The follow-up 'so how do you get parallelism?' has one answer: multiple worker processes. Async gives concurrency, not CPU parallelism.
Watch a deeper explanation
Video: Python API Development - Comprehensive Course for Beginners (freeCodeCamp.org, YouTube)
Run multiple uvicorn worker processes (often via Gunicorn with the uvicorn worker class or uvicorn's own workers), roughly matched to CPU cores, behind a reverse proxy like nginx. Each worker has its own event loop, so processes give you the CPU parallelism async alone can't.
Beyond one box, put the app in containers and scale horizontally behind a load balancer, keep the app stateless, and move shared state to Redis or the database.
| Scaling axis | Mechanism | Buys you |
|---|---|---|
| Concurrency in a worker | async + await on I/O | Many in-flight I/O-bound requests |
| CPU parallelism | Multiple worker processes | Uses all cores |
| Horizontal scale | More containers + load balancer | Handles more total traffic |
Key point: The distinction between concurrency (async) and parallelism (processes) is exactly what this question tests. Keep them separate in your answer.
Keep it off the event loop. Options in ascending effort: run it in a threadpool with run_in_executor if the work releases the GIL (NumPy, some C libraries), offload to a separate process pool, or push it to an external worker system like Celery and return a job id the client polls.
The anti-pattern is doing heavy CPU work directly in an async route: it blocks the loop and every concurrent request stalls. Async is for I/O concurrency, not computation.
import asyncio
@app.post("/hash")
async def hash_endpoint(data: bytes):
loop = asyncio.get_running_loop()
# offload the CPU work so the loop stays free
result = await loop.run_in_executor(None, expensive_hash, data)
return {"hash": result}Use the lifespan context manager passed to FastAPI(). Code before the yield runs on startup (open a DB pool, warm a cache, load a model), code after the yield runs on shutdown (close connections). It replaces the older @app.on_event('startup'/'shutdown') hooks.
This is where you create resources that should live for the whole process, not per request.
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
app.state.pool = await create_pool() # startup
yield
await app.state.pool.close() # shutdown
app = FastAPI(lifespan=lifespan)Measure first: add timing, check the database, and look for the usual suspect, the N+1 query, where serializing each item triggers another lazy query. Then check for a blocking sync call sitting in an async route, missing pagination, and no response_model so you're serializing more than you send.
Fix in order of impact: eager-load or batch the queries, paginate, cache hot reads, and only then think about workers. The methodology (observe, hypothesize, verify, fix, confirm) matters more than any single tool.
Debugging a slow FastAPI list endpoint
The methodology is; tools support the evidence.
Key point: N+1 is the answer they're fishing for on list endpoints. Naming it fast, then giving the fix, lands well.
Pydantic v2's Rust core made validation fast, but validating and serializing large or deeply nested payloads still costs CPU on every request. On hot paths that return big lists or wide objects, that per-request cost adds up and shows in your latency numbers.
Mitigations: use response_model_exclude to trim output, avoid revalidating data you already trust, consider a lighter serialization path for very hot endpoints, and don't wrap huge internal blobs in models when a plain dict serialized once is enough.
A dependency can itself declare dependencies, forming a tree that FastAPI resolves before your route runs. Within a single request, if the same dependency appears more than once, FastAPI calls it once and reuses the result by default; you can disable that with use_cache=False.
This lets you compose auth on top of a DB session on top of settings, cleanly, without re-running shared work.
def get_db():
...
def get_repo(db = Depends(get_db)):
return Repository(db)
def current_user(repo = Depends(get_repo)):
... # get_db is resolved once and shared this requestPass dependencies to APIRouter or to FastAPI() itself, or to include_router. Every route under that scope runs those dependencies, which is the clean way to enforce auth or rate limiting across a whole section without repeating Depends on each route.
These router-level dependencies run for their side effects (auth checks, logging); their return values aren't injected into the route.
admin = APIRouter(
prefix="/admin",
dependencies=[Depends(require_admin)], # applies to every admin route
)
@admin.get("/stats")
def stats():
return {"ok": True}The common approach is URL prefixes: mount a v1 router at /v1 and a v2 router at /v2, each its own set of routes and models. Header-based versioning is possible but less discoverable in the auto docs.
Keep old versions working while consumers migrate, and share unchanged logic between versions rather than forking everything.
Clarify the requirements first (per-user or per-IP, burst tolerance, single process or distributed), then pick the mechanism. Single process: a dependency or middleware holding a counter per key. Distributed: the state must live in Redis (atomic INCR with EXPIRE, or a token bucket in a Lua script), because per-process memory can't see global traffic across workers.
Return 429 with a Retry-After header on rejection, emit metrics on rejects, and put the check in a router-level dependency so it covers a whole section. Structuring the answer clarify, mechanism, storage, edges is what the question scores.
from fastapi import Depends, HTTPException, Request
async def rate_limit(request: Request):
key = f"rl:{request.client.host}"
count = await redis.incr(key)
if count == 1:
await redis.expire(key, 60)
if count > 100:
raise HTTPException(429, "rate limit exceeded",
headers={"Retry-After": "60"})Key point: The single-process-vs-distributed split is the heart of this. Saying 'state goes to Redis across workers' shows you've thought past one machine.
Use a dedicated response_model that declares exactly the public fields, and the parameters response_model_exclude, response_model_include, and response_model_exclude_none for finer control. A separate output model is the safest default because it can never leak an internal field.
The failure mode is returning the raw ORM object with sensitive fields; a response model closes that hole by construction.
With SQLAlchemy's async support, create an async engine and async session, and inject the session through a yield dependency using async with. Routes are async def and await every query. The whole path must be async: mixing a sync driver into an async session or forgetting an await is where bugs and blocking creep in.
If any part of your stack is only sync, it's often cleaner to run the whole endpoint as plain def and let FastAPI's threadpool handle the blocking, rather than half-migrating to async.
from sqlalchemy.ext.asyncio import AsyncSession
async def get_db():
async with AsyncSessionLocal() as session:
yield session
@app.get("/users/{id}")
async def get_user(id: int, db: AsyncSession = Depends(get_db)):
user = await db.get(User, id)
return userValidate all input (Pydantic already does the shape; add business rules), hash passwords with a strong algorithm, use short-lived tokens with proper verification, restrict CORS to known origins, and set security headers. Rate-limit auth endpoints, and never return stack traces to clients in production.
Also: use parameterized queries or an ORM to avoid injection, keep secrets in the environment or a secret manager, and put the app behind HTTPS terminated at the proxy.
app.dependency_overrides maps a real dependency to a fake one for the duration of a test. Swap the real database session for an in-memory or transactional test one, or the real current_user for a fixed test user, without touching route code.
This is the concrete payoff of dependency injection: production wiring and test wiring differ in one dictionary, not in scattered mocks.
def override_db():
yield test_session
app.dependency_overrides[get_db] = override_db
# now every route that Depends(get_db) uses the test session
# clear it after: app.dependency_overrides.clear()Structured logging with a correlation id per request (set in middleware), metrics via a Prometheus client exposing a /metrics endpoint, and distributed tracing with OpenTelemetry instrumentation that ties spans across services. Middleware is the natural place to time requests and attach the request id.
The goal is that when an endpoint misbehaves in production you can see which request, how long each stage took, and where it called downstream, without adding print statements after the fact.
The ASGI server (uvicorn) stops accepting new connections on a shutdown signal and waits for in-flight requests to finish within a timeout, then runs the lifespan shutdown block to close pools and flush buffers. In containers, honor SIGTERM and set a sensible grace period so a deploy doesn't drop live requests.
For work that must not be lost, hand it to a durable queue rather than a BackgroundTask, since in-process background work dies with the process.
Split when parts of the system have different scaling needs, deploy cadences, or team ownership, not just because services sound cleaner. A FastAPI app scales fine as a well-structured monolith for a long time.
What breaks on splitting: a local function call becomes a network call that can fail and add latency, transactions no longer span services, and you inherit service discovery, retries, and distributed tracing. The production-ready answer weighs those costs against the benefit rather than defaulting to microservices.
Key point: the technical value is restraint here. 'Stay a monolith until a real force pushes you to split' is a more experienced answer than 'microservices scale better'.
Middleware wraps every request and response and is right for truly global concerns: request timing, a correlation id, catch-all error shaping, and response headers. A dependency is scoped and injectable: auth for specific routes, a per-request DB session, shared params.
Rule of thumb: if it applies to everything and touches the raw response, use middleware; if it produces a value a route needs or applies to a subset, use a dependency.
The generated OpenAPI schema is machine-readable, so you can generate typed client SDKs (TypeScript, Python, and others) directly from it, keeping frontend and backend contracts in sync. Contract tests and mock servers can be driven from the same schema.
That's a real productivity gain: the API contract is a single source of truth derived from your Python types, not a hand-maintained document that drifts.
Mark the route deprecated=True so it shows as deprecated in the docs, keep it working, and add the replacement alongside it. Communicate a timeline, emit metrics on the old route to see who still calls it, and only remove it once usage drops to zero.
Versioned prefixes make this cleaner: the old version keeps serving while the new one ships, and removal is a whole-version decision rather than a surprise.
@app.get("/old-report", deprecated=True)
def old_report():
return legacy_report()
@app.get("/reports")
def new_report():
return report()FastAPI wins when you're building an API and want async support, automatic validation, and generated docs out of the box. Flask is lighter and unopinionated, so you assemble your own validation and docs. Django REST Framework brings batteries (ORM, admin, auth) but is heavier and traditionally synchronous. The honest trade-off: FastAPI's async model and Pydantic validation are its edge, but that same async model is where candidates get burned by putting blocking code on the event loop. Saying these differences out loud is itself an interview signal.
| Framework | Async model | Validation | Best at |
|---|---|---|---|
| FastAPI | Native async (ASGI) | Built in (Pydantic) | Modern APIs, auto docs, type safety |
| Flask | Sync by default (async optional) | Add a library | Small apps, full control, simplicity |
| Django REST Framework | Sync-oriented | Serializers | Full-stack apps with an ORM and admin |
| Express (Node) | Native async | Add a library | JavaScript teams, real-time apps |
Prepare in layers, and practice out loud. Most FastAPI rounds move from concept questions to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers. Async and Pydantic are where interviewers dig deepest.
The typical FastAPI interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These FastAPI questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works