Unit 12.00: The database session, per request
One database session per request, always closed, even when the handler raises.
Yield, then finally
A session dependency using yield, exercised over three requests.
The code confirms every session opened was closed.
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
OPENED, CLOSED = [], []
class Session:
def __init__(self, n): self.n = n
def query(self): return f"rows from session {self.n}"
def get_session():
session = Session(len(OPENED) + 1)
OPENED.append(session.n)
try:
yield session
finally:
CLOSED.append(session.n)
app = FastAPI()
@app.get("/rows")
def rows(session: Session = Depends(get_session)) -> dict:
return {"data": session.query()}
client = TestClient(app)
for _ in range(3):
client.get("/rows")
print(f"opened: {OPENED}")
print(f"closed: {CLOSED}")
print(f"all closed: {OPENED == CLOSED}")
# `yield` plus `finally` is the shape: one session per request, always closed,
# even when the handler raises. A session shared across requests leaks state
# between callers and eventually deadlocks.
The finally runs whether the handler returned or raised, which is what makes the guarantee hold. A session leaked on the error path exhausts the pool slowly, so the symptom appears hours later and under load.
One session per request is the other half. A session shared across requests leaks state between callers and eventually deadlocks.
The mistake this prevents
The mistake is opening a session at module level and reusing it. It works in development with one request at a time, and fails under concurrency in ways that look like data corruption.
Takeaway
Use a yield dependency with finally for sessions. One per request, always closed - a leak on the error path surfaces as exhaustion much later.
