Skip to content
C Codeloom
FastAPI

FastAPI: Async Routes and Dependency Injection

A practical guide to async path operations and Depends() in FastAPI — when async actually helps, per-request DB sessions, auth dependencies, and how sub-dependencies compose.

·8 min read · By Yash Kesharwani
Intermediate 12 min read

What you'll learn

  • When `async def` routes help and when they do not
  • How FastAPI runs sync and async handlers under one server
  • How Depends() injects values into your path operations
  • A per-request database session pattern with yield
  • A simple authentication dependency you can build on
  • How sub-dependencies compose into a clean dependency tree

Prerequisites

Two FastAPI features take the framework from “nice typed API” to “actually pleasant to work in”: async routes and the Depends() injection system. They are also two of the most misunderstood. This post explains what each one really does, where it helps, and how to combine them in real code.

async def in one minute

A FastAPI path operation can be either def or async def:

from fastapi import FastAPI

app = FastAPI()

@app.get("/sync")
def sync_handler() -> dict[str, str]:
    return {"kind": "sync"}

@app.get("/async")
async def async_handler() -> dict[str, str]:
    return {"kind": "async"}

Both work. The difference is how the server runs them.

  • async def: runs directly on the event loop. When you await inside, the loop is free to handle other requests until your await returns.
  • def: runs on a worker thread pool. The event loop hands the call to a thread so the loop is not blocked. There can only be so many threads, but the loop itself never pauses.

FastAPI handles both correctly. You can mix and match.

When async actually helps

Async pays off when your handler waits on I/O. The classic shapes:

  • HTTP calls to other services (use httpx.AsyncClient)
  • Async database drivers (asyncpg, motor, the async SQLAlchemy stack)
  • Streaming, long polling, websockets
  • LLM calls and other model-as-a-service backends

Async does not help when:

  • Your handler does CPU work — async def cannot parallelise CPU.
  • You only call synchronous libraries (sync DB drivers, blocking SDKs). Inside async def those calls block the entire event loop. Either run them under def, or wrap them with asyncio.to_thread.

The dirty secret: an async def handler that calls a blocking function is worse than the def version, because the blocking call now sits on the event loop instead of a worker thread. If you cannot make the I/O async, use def.

import asyncio
import httpx

@app.get("/joined")
async def joined() -> dict[str, int]:
    # Two real HTTP calls running concurrently — this is where async shines.
    async with httpx.AsyncClient() as client:
        a, b = await asyncio.gather(
            client.get("https://api.example.com/a"),
            client.get("https://api.example.com/b"),
        )
    return {"a": a.status_code, "b": b.status_code}

Two outgoing requests overlap. A synchronous version would serialise them.

Depends() — values into your handlers

A path operation function can have parameters that are filled by FastAPI calling another function for you. That is what Depends() does.

from fastapi import Depends, FastAPI

app = FastAPI()

def settings() -> dict[str, str]:
    return {"site_name": "CodeLoom"}

@app.get("/about")
def about(cfg: dict = Depends(settings)) -> dict[str, str]:
    return {"name": cfg["site_name"]}

FastAPI sees the Depends(settings) annotation, calls settings() per request, and passes the result as cfg. From your handler’s perspective, cfg is just an argument.

Why bother? Three things:

  • Testability. You can override the dependency in tests to inject a fake.
  • Reuse. Many routes can share the same setup code without copying it.
  • Composition. Dependencies can themselves depend on other dependencies.

The modern annotation style

With Python 3.10+ and Annotated, the dependency moves into the type and the parameter list reads cleanly:

from typing import Annotated
from fastapi import Depends, FastAPI

app = FastAPI()

def settings() -> dict[str, str]:
    return {"site_name": "CodeLoom"}

Settings = Annotated[dict, Depends(settings)]

@app.get("/about")
def about(cfg: Settings) -> dict[str, str]:
    return {"name": cfg["site_name"]}

The alias Settings is reusable across every route that needs it. This is the recommended FastAPI style today.

A per-request database session

The classic dependency pattern. Open a session for the request, hand it to the handler, close it on the way out. A generator with yield does the dance:

from typing import Annotated, Iterator
from fastapi import Depends
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy import create_engine

engine = create_engine("sqlite:///./app.db", future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)

def get_db() -> Iterator[Session]:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

DB = Annotated[Session, Depends(get_db)]

@app.get("/users/{user_id}")
def read_user(user_id: int, db: DB):
    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="not found")
    return user

Important: the code after yield runs after the response is sent. That is where you close the session, commit a transaction, release a lock. If the handler raises, FastAPI still runs the cleanup.

For async SQLAlchemy, the same pattern with async def get_db() and an AsyncSession:

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

engine = create_async_engine("postgresql+asyncpg://localhost/app")
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db() -> "AsyncIterator[AsyncSession]":
    async with AsyncSessionLocal() as db:
        yield db

The async with block handles both setup and teardown idiomatically.

Try it yourself. Add a print before yield and another in finally in the sync get_db. Hit a route, then a route that raises HTTPException. Confirm the finally runs in both cases. Then send a request that 404s and confirm it still runs.

An authentication dependency

A dependency can read the incoming request directly. The cleanest way is via FastAPI’s security utilities, which also document themselves in OpenAPI:

from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

class User(BaseModel):
    id: int
    name: str

def decode_token(token: str) -> User | None:
    # Replace with real JWT decode + DB lookup.
    if token == "dev-token":
        return User(id=1, name="Ada")
    return None

def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    user = decode_token(token)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return user

CurrentUser = Annotated[User, Depends(get_current_user)]

@app.get("/me")
def me(user: CurrentUser) -> User:
    return user

OAuth2PasswordBearer extracts the bearer token from the Authorization header. get_current_user turns the token into a User or raises 401. Every protected route just declares user: CurrentUser and reads the value.

The /docs page now shows a lock icon next to protected endpoints and a button to authorise the browser session.

Role-based dependencies

Dependencies compose. A role check sits on top of get_current_user:

def require_admin(user: CurrentUser) -> User:
    if user.name != "Ada":         # placeholder — use a real role field
        raise HTTPException(status_code=403, detail="Admins only")
    return user

AdminUser = Annotated[User, Depends(require_admin)]

@app.delete("/items/{item_id}")
def delete_item(item_id: int, admin: AdminUser):
    return {"deleted": item_id, "by": admin.name}

require_admin depends on get_current_user, which depends on oauth2_scheme. Each piece is small and individually testable; the route just asks for AdminUser and gets the policy enforced for free.

Sub-dependencies in general

The pattern above is the general rule: any dependency can declare its own Depends(...) parameters, and FastAPI resolves the entire tree before calling your handler.

def get_settings() -> dict[str, str]:
    return {"region": "eu-west-1"}

def get_logger(settings: Annotated[dict, Depends(get_settings)]):
    region = settings["region"]
    def log(msg: str) -> None:
        print(f"[{region}] {msg}")
    return log

@app.post("/orders")
def create_order(
    log: Annotated[callable, Depends(get_logger)],
):
    log("order created")
    return {"ok": True}

FastAPI calls get_settings, passes the result to get_logger, and passes that result to the handler — all once per request, automatically.

By default, identical Depends(...) calls in the same request share their result — the function runs once and its value is reused. If you do not want that, pass use_cache=False to the dependency.

Path-level and app-level dependencies

A dependency you do not need the value from can be attached to the route or the whole app:

def verify_api_key(x_api_key: Annotated[str, Header()]) -> None:
    if x_api_key != "secret":
        raise HTTPException(status_code=401, detail="bad api key")

@app.get("/widgets", dependencies=[Depends(verify_api_key)])
def list_widgets():
    return []

The dependency runs and can raise; you do not need a parameter to receive its (unused) return value. Use the same pattern with app = FastAPI(dependencies=[...]) to apply a check globally.

Try it yourself. Write a request_id dependency that returns a UUID per request. Add it to two routes. Confirm both routes get the same UUID within a request (cache) but a different one across requests. Then add use_cache=False and watch the IDs differ within a request too.

Overriding dependencies in tests

The whole point of Depends() shines here. In a pytest setup:

from fastapi.testclient import TestClient

def fake_db() -> Iterator[Session]:
    db = TestSessionLocal()
    try:
        yield db
    finally:
        db.close()

app.dependency_overrides[get_db] = fake_db

client = TestClient(app)
res = client.get("/users/1")
assert res.status_code == 200

Production code points at the real database; tests point at a SQLite or transactional fake. The route does not change.

Recap

You now know:

  • async def is for I/O-bound handlers — HTTP, async DB, websockets, LLM calls.
  • def is fine for everything else; FastAPI runs it on a worker thread.
  • Calling blocking code inside async def is the worst of both worlds.
  • Depends() injects per-request values into your handlers; with Annotated, type aliases make this clean.
  • A generator dependency with yield is the canonical per-request session pattern.
  • Authentication is just another dependency, layered on top of FastAPI’s security utilities.
  • Sub-dependencies compose into a tree; identical deps are cached per request unless you disable it.
  • app.dependency_overrides makes the entire system trivially testable.

Next steps

The next post wires FastAPI to a real database with SQLAlchemy 2.0 — typed models with Mapped and mapped_column, a get_db dependency in practice, full CRUD routes, and where Alembic fits.

→ Next: FastAPI + SQLAlchemy

Related: Routes and Pydantic Models, What Is FastAPI?.

Questions or feedback? Email codeloomdevv@gmail.com.