FastAPI Dependency Injection and Async
The Depends() system for shared setup like DB sessions and auth, plus async def vs def.
What Depends() solves
Most endpoints need some shared setup before the real logic runs — a database session, the currently authenticated user, a set of pagination defaults. FastAPI's dependency injection system lets you write that setup once, as a plain function, and have FastAPI run it automatically for every endpoint that declares it needs it via Depends().
from fastapi import Depends, FastAPI
app = FastAPI()
def get_db():
db = SessionLocal() # open a database session
try:
yield db # hand it to the endpoint
finally:
db.close() # always runs, even if the endpoint raised an exception
@app.get("/users/{user_id}")
def read_user(user_id: int, db=Depends(get_db)):
return db.query(User).filter(User.id == user_id).first()
Depends(get_db) tells FastAPI: before calling read_user, call get_db(), and pass whatever it yields as the db argument. Using yield instead of return lets the dependency run cleanup code (closing the session) after the endpoint finishes — the code after yield always runs, success or failure, similar in spirit to a try/finally block or a context manager.
A real example: an auth dependency
Dependencies compose naturally for cross-cutting concerns like authentication — write the check once, reuse it on every route that needs it:
from fastapi import Depends, FastAPI, Header, HTTPException
app = FastAPI()
def get_current_user(authorization: str = Header(...)):
token = authorization.removeprefix("Bearer ").strip()
user = verify_token(token) # your own token validation logic
if user is None:
raise HTTPException(status_code=401, detail="Invalid or missing token")
return user
@app.get("/me")
def read_current_user(user=Depends(get_current_user)):
return {"id": user.id, "name": user.name}
@app.get("/orders")
def list_orders(user=Depends(get_current_user)):
return fetch_orders_for(user.id)
Both read_current_user and list_orders require a valid Authorization header — an unauthenticated request never reaches either endpoint's body, and the 401 response is raised from one shared place instead of being duplicated in every view.
Dependencies can depend on other dependencies
Depends() chains naturally — a dependency's own parameters can themselves be Depends() values, and FastAPI resolves the whole chain in order:
def get_current_user(authorization: str = Header(...)):
...
return user
def require_admin(user=Depends(get_current_user)):
if not user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return user
@app.delete("/users/{user_id}")
def delete_user(user_id: int, admin=Depends(require_admin)):
...
async def vs plain def — when it actually matters
FastAPI supports both async def and plain def for path operation functions (and for dependencies), and the choice matters specifically around blocking I/O:
async def |
plain def |
|
|---|---|---|
| Runs on | The main event loop | A separate thread pool FastAPI manages for you |
| Best for | Awaiting async I/O (httpx.AsyncClient, an async DB driver) |
Blocking/synchronous calls (a plain requests call, a sync DB driver, CPU-bound work) |
| The danger | Calling blocking code inside it stalls the entire event loop — every other concurrent request stalls too | None of that risk — it's already isolated on its own thread |
import httpx
@app.get("/proxy-async")
async def call_other_api_async():
async with httpx.AsyncClient() as client:
response = await client.get("https://api.example.com/data")
return response.json()
@app.get("/proxy-sync")
def call_other_api_sync():
import requests
response = requests.get("https://api.example.com/data") # blocking, but that's fine here
return response.json()
Both of these are reasonable — the difference is that call_other_api_async uses a genuinely async HTTP client (httpx.AsyncClient) so the event loop is free to serve other requests while waiting on the network, while call_other_api_sync uses the ordinary blocking requests library, which FastAPI safely offloads to a worker thread since the function is plain def.
The mistake to avoid is mixing the two the wrong way: writing async def and calling a blocking library like requests (or time.sleep) directly inside it. That blocks the single event loop thread, and every other request FastAPI is currently handling stalls until that call returns — a much worse outcome than just using plain def in the first place.
Common mistakes
- Calling blocking, synchronous code (a sync DB driver,
requests.get,time.sleep) directly inside anasync defendpoint — this blocks the entire event loop, not just that one request. - Marking every endpoint
async def"for performance" without an actual async I/O call inside it — with no genuine async operation toawait, plaindef(safely run in a thread pool) is just as fast and less error-prone. - Forgetting a dependency using
yieldneeds its cleanup code wrapped intry/finally— otherwise a raised exception from the endpoint skips the cleanup (like closing a database session) entirely.
Interview questions
Q: What problem does Depends() solve, in plain terms?
It lets you factor out setup logic that many endpoints share — opening a DB session, checking authentication, parsing common query parameters — into one reusable function, instead of copy-pasting that logic (and its error handling) into every endpoint that needs it. FastAPI calls the dependency automatically and passes its return (or yielded) value into your endpoint as a normal argument.
Q: When should a FastAPI path operation be async def, and when should it just be def?
Use async def when the endpoint awaits genuinely asynchronous I/O — an async database driver, httpx.AsyncClient, another async def dependency — so the event loop stays free to handle other requests while waiting. Use plain def when the endpoint calls blocking/synchronous code (a sync ORM, requests, CPU-bound work); FastAPI automatically runs plain def endpoints in a separate thread pool, so blocking calls there don't freeze the whole server the way they would inside an async def function.