Request‑Scoped Caching in FastAPI Dependency Injection: When and How to Use Sub‑Dependencies
Learn how FastAPI caches dependencies per request, shares sub‑dependency instances, and lets you swap them in tests without touching production data.
14 Apr 2026, 12:51 UTC

The problem: reusing a database session across multiple services
When building a FastAPI endpoint you often need a database session in several places: a repository dependency, a validation service, and the route handler itself. Creating a new Session for each would waste connections and break transaction boundaries. You want a single session per HTTP request that is safely shared and automatically closed when the request ends.
Thesis: FastAPI’s dependency injector caches each dependency per request, and sub‑dependencies automatically receive that cached instance
When you declare a dependency with Depends(), FastAPI calls the function once per request, stores the return value in a request‑scoped cache, and injects that cached value wherever the same dependency appears. If a dependency itself has its own Depends() arguments (sub‑dependencies), FastAPI resolves those first and reuses their cached results. This means a get_db function that yields a SQLAlchemy Session will produce exactly one session per request, no matter how many other dependencies rely on it.
Worked example: a session‑yielding dependency and a service that consumes it
First, install the required packages (run in your development environment):
pip install fastapi uvicorn sqlalchemy
Create a file main.py with the following content:
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import sessionmaker, declarative_base
# ---- Database setup (example SQLite file) ----
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
Base.metadata.create_all(bind=engine)
# ---- Dependency that yields a Session ----
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# ---- A service that depends on the session ----
def get_item_repo(db: Session = Depends(get_db)):
# Simple repository object; could be a class with methods
class ItemRepo:
def __init__(self, session):
self.session = session
def get(self, item_id: int):
return self.session.query(Item).filter(Item.id == item_id).first()
return ItemRepo(db)
# ---- FastAPI app ----
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(item_id: int, repo: ItemRepo = Depends(get_item_repo)):
item = repo.get(item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return {"id": item.id, "name": item.name}
Where to run: execute uvicorn main:app --reload in a terminal with normal user permissions. The get_db function is a dependency; FastAPI calls it once per request, yielding a Session. The get_item_repo dependency declares its own Depends(get_db), so FastAPI resolves get_db first, caches the session, and passes that same session to the repository factory. The route handler receives the repository, which already holds the request‑scoped session.
Testing the behavior with dependency overrides
To verify that the session is truly request‑scoped and closed after the request, write a test using TestClient and override get_db with an in‑memory SQLite engine:
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Override dependency: in‑memory SQLite for tests
def override_get_db():
test_engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
Base.metadata.create_all(bind=test_engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
# Insert a test item directly via the overridden session
def test_read_item():
from sqlalchemy.orm import Session
# Get the overridden session to seed data
db: Session = next(override_get_db())
db.add(Item(name="test-item"))
db.commit()
db.close()
response = client.get("/items/1")
assert response.status_code == 200
assert response.json() == {"id": 1, "name": "test-item"}
# After the request, the overridden session should be closed;
# attempting to use it raises an error.
db = next(override_get_db())
try:
db.query(Item).first() # This will raise because the session is closed
assert False, "Expected an error"
except Exception:
pass # Expected
if __name__ == "__main__":
test_read_item()
print("Test passed")
Where to run: save the test in test_main.py and execute python test_main.py. No production database is touched because the override replaces get_db for the duration of the test.
Trade‑offs and limitations
- Sync vs. async: If your path operation is
async def, any dependency that performs blocking I/O must be declaredasync defand awaited withawait Depends(...). Mixing a sync dependency in an async route blocks the event loop. - Over‑scoping: Declaring a dependency as a global singleton (e.g.,
@lru_cacheon a function that returns a mutable object) can leak state across requests. Use request‑scoped caching unless you explicitly need sharing. - Forcing a fresh instance: Set
use_cache=FalseinDepends(some_func, use_cache=False)when you deliberately want a new object each time, such as a per‑handler logger with request‑specific context.
Practical way to check the result
After a request, you can confirm that the session is closed by attempting to execute a query outside the request context (as shown in the test). If the session is still open, the query will succeed; if closed, you will receive an error like SQLException: Cannot operate on a closed database. This simple check validates that FastAPI’s cleanup (finally: db.close()) ran as expected.
Actionable closing
To adopt this pattern in your own services:
- Identify objects that should live for the length of an HTTP request (DB sessions, request‑scoped caches, tenant context).
- Write a dependency function that yields or returns the object and ensure any resources are released in a
finallyblock. - Consume that dependency wherever needed; FastAPI will share the cached instance via sub‑dependencies.
- In tests, override the dependency with an in‑memory or fake implementation using
app.dependency_overrides. - Verify sync/async compatibility and avoid accidental singletons unless intentional sharing is required.
By following these steps you get reusable, testable code without manually managing lifecycles.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.