What a request-scoped database session is and why it belongs to the request

A request-scoped database session is a SQLAlchemy Session that is created when a request arrives, used by everything that handles that request, and closed when the request finishes. In FastAPI, a single dependency that uses yield owns this lifecycle. The code before yield opens the session, the endpoint and its sub-dependencies receive it, and the code after yield closes it during request teardown, even when the handler raises.

The common alternative is a session created once at module import or application startup. It fails in three ways:

  • It is shared across concurrent requests. A Session is not safe to use from multiple threads or tasks at once. FastAPI runs sync endpoints in a threadpool, so two requests can call add, flush, or rollback on the same object at the same time. One request's rollback can discard another request's pending writes.
  • It keeps stale identity-map state. The session caches every object it loads. A long-lived session can return a row that another process changed minutes ago, and memory grows as the cache accumulates objects.
  • It leaves transactions open. A session begins a transaction on first use and holds a pooled connection until it commits, rolls back, or closes. A shared session that nobody closes can hold locks and connections indefinitely.

A session per request gives each request its own unit of work: one identity map, one transaction, and one clear point where changes either commit or roll back.

This pattern suits backend and platform engineers running FastAPI with SQLAlchemy who want every endpoint to acquire, scope, and release sessions the same way, without each handler managing that itself.

      Request A                       Request B
             |                               |
             v                               v
+--------------------------+    +--------------------------+
| get_db() dependency      |    | get_db() dependency      |
|  before yield: Session() |    |  before yield: Session() |
+------------+-------------+    +------------+-------------+
             | yield session                 | yield session
             v                               v
+--------------------------+    +--------------------------+
| endpoint + sub-deps      |    | endpoint + sub-deps      |
|  Session A               |    |  Session B               |
|  - own identity map      |    |  - own identity map      |
|  - own transaction       |    |  - own transaction       |
+------------+-------------+    +------------+-------------+
             | teardown (even on error)      | teardown (even on error)
             v                               v
+--------------------------+    +--------------------------+
| after yield:             |    | after yield:             |
|  commit/rollback, close  |    |  commit/rollback, close  |
+------------+-------------+    +------------+-------------+
             | connection returned           | connection returned
             v                               v
+----------------------------------------------------------+
|  Engine connection pool  ---->  Database                 |
+----------------------------------------------------------+

Each request gets its own session from a single yield dependency, with its own identity map and transaction, and the code after yield closes the session and returns its connection to the pool even when the handler raises.

How to build get_db with Depends and yield

Start with the expensive objects. Call create_engine once at module import or in a lifespan handler, and build a sessionmaker bound to that engine at the same time. The engine owns the connection pool. If you create one per request, you throw away pooling and open a new connection every time. The sessionmaker is a cheap, configured factory, so the per-request work is just calling it.

get_db is then a generator function:

  • It calls the factory to get a session.
  • It yields that session inside a try block.
  • It calls session.close() in the matching finally.

FastAPI treats everything before yield as setup and everything after it as teardown. The yielded value is injected into the endpoint. When the endpoint's work is done, FastAPI resumes the generator and the teardown runs. The finally is important because it also runs when the handler raises, so the connection always goes back to the pool.

Do not assume teardown completes before the client receives the response. The ordering of teardown relative to sending the response has changed across FastAPI releases. If your design depends on it, for example by committing in teardown and expecting the client to see the result, check the behavior of the version you run.

FastAPI also caches each dependency per request. If an endpoint, a repository dependency and an auth dependency all declare Depends(get_db), they all receive the same session within that request, which is what you want for one unit of work. Passing use_cache=False gives each caller its own session and transaction. That splits the unit of work and is rarely correct for sessions.

from typing import Annotated, Iterator

from fastapi import Depends, FastAPI
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker

# Created once at import: the engine owns the connection pool.
engine = create_engine("postgresql+psycopg://app:change-me@localhost:5432/appdb")
# Cheap, configured factory; calling it per request is the only per-request cost.
SessionLocal = sessionmaker(bind=engine, autoflush=False)

app = FastAPI()


def get_db() -> Iterator[Session]:
    db = SessionLocal()  # setup: runs before the endpoint
    try:
        yield db  # injected into the endpoint and other dependencies
    finally:
        # teardown: runs after the endpoint's work, even if it raised.
        # Do not rely on this finishing before the client gets the response.
        db.close()  # returns the connection to the pool


DbSession = Annotated[Session, Depends(get_db)]


def get_current_user_id(db: DbSession) -> int:
    # Same request -> same cached session object as the endpoint receives.
    return db.execute(text("SELECT 1")).scalar_one()


@app.get("/whoami")
def whoami(db: DbSession, user_id: Annotated[int, Depends(get_current_user_id)]):
    # get_db ran once for this request; both parameters share one session.
    # Depends(get_db, use_cache=False) would open a second session and transaction.
    return {"user_id": user_id, "session_id": id(db)}

A get_db generator dependency that reuses a module-level engine and sessionmaker, closes the session in a finally block, and is shared by the endpoint and an auth dependency through per-request caching.

Where commits happen: endpoint, service layer, or dependency

The other option is to let get_db own the transaction. Wrap the yield in try/except/finally: commit after the yield returns, roll back in except, and close in finally. This works because FastAPI throws any exception raised in the endpoint back into the dependency at the yield point, so the except block sees the same error the handler raised. Re-raise it after rolling back. If you swallow it, the error never reaches FastAPI's exception handlers.

Where you commit What you gain What it costs
Endpoint or service layer Transaction boundaries are explicit, and you can commit several times Every write path needs its own commit and rollback code
Dependency after yield One commit and rollback path for all routes Boundaries are hidden, and the commit runs late

The dependency approach removes boilerplate until an endpoint needs to commit before a side effect, such as publishing an event or calling a payment API. It also gets awkward when one request needs two separate transactions. Code after yield may also run after the response has been produced, so a failed commit can follow a response the client already treated as success.

Wherever you commit, watch for detached instances. Once the session closes, touching an unloaded relationship raises DetachedInstanceError. With expire_on_commit enabled, a commit expires every loaded attribute, so reading an attribute after the session closes fails the same way. There are two fixes:

  • Eager loading (selectinload, joinedload) loads the data up front. It adds queries or wider joins on every call, including calls that never use the relationship.
  • Disabling expire_on_commit keeps the loaded values. They are a snapshot that can be stale if another transaction changed the rows.
from fastapi import Depends, FastAPI
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, selectinload, sessionmaker

from app.models import User  # User.posts is a lazy-loaded relationship

engine = create_engine("postgresql+psycopg://app:change_me@localhost:5432/appdb")
# expire_on_commit=True is the default: commit() expires every loaded attribute
SessionLocal = sessionmaker(bind=engine, expire_on_commit=True)

app = FastAPI()


def get_db():
    db = SessionLocal()
    try:
        yield db      # the endpoint runs here; its exception is thrown back in at this point
        db.commit()   # reached only if the endpoint returned without raising
    except Exception:
        db.rollback()
        raise         # re-raise so FastAPI's exception handlers still see the error
    finally:
        db.close()    # after this, unloaded or expired attributes raise DetachedInstanceError


@app.get("/users/{user_id}")
def read_user(user_id: int, db: Session = Depends(get_db)):
    # selectinload fetches posts now with one extra SELECT, instead of lazily later
    stmt = select(User).where(User.id == user_id).options(selectinload(User.posts))
    user = db.scalars(stmt).one()
    # Copy into plain data while the session is still open, so nothing
    # touches the ORM object after the dependency commits and closes it
    return {
        "id": user.id,
        "name": user.name,
        "posts": [p.title for p in user.posts],
    }

A get_db dependency that commits after yield, rolls back and re-raises when the endpoint fails, and closes the session in finally, with an endpoint that eager-loads a relationship so it never lazy-loads from a closed session.

Composing repository dependencies and choosing sync or async

Endpoints should not ask for a session at all. They should ask for the thing they use. A get_user_repository dependency declares db: Session = Depends(get_db) and returns a UserRepository wrapping that session. The endpoint then declares users: UserRepository = Depends(get_user_repository) and never imports SQLAlchemy. FastAPI caches dependency results per request by default. If a UserRepository and an OrderRepository both depend on get_db, they get the same session. Their writes therefore land in one transaction, which is what you want when a single request touches both tables.

The repository dependency must match the driver underneath it.

Aspect Sync Async
Dependency form def async def
Session type Session AsyncSession
Driver Blocking DBAPI driver Asyncio-native driver
Where it runs FastAPI's threadpool The event loop
Main risk Threadpool exhaustion under load Blocking calls stalling the loop

Sync variant

A sync def dependency is safe because FastAPI runs it in a worker thread. A slow query blocks that thread and leaves the event loop free.

Async variant

The async variant uses async def and is only correct with an async driver. A blocking driver called from an async def dependency or endpoint does not run in the threadpool. It runs on the event loop, and every other in-flight request waits for that query to finish.

An AsyncSession is not safe for concurrent use. If an endpoint uses asyncio.gather to run several queries in parallel tasks, those tasks must not share the request's session. Run the queries sequentially on that session, or give each task its own session from the sessionmaker.

import asyncio
from typing import AsyncIterator
from fastapi import Depends, FastAPI
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

# Async path needs an asyncio-native driver (asyncpg), never a blocking one.
engine = create_async_engine("postgresql+asyncpg://app:change-me@db.internal:5432/app")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
app = FastAPI()

class Base(DeclarativeBase): pass
class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str]

async def get_db() -> AsyncIterator[AsyncSession]:
    async with SessionLocal() as session:  # one session per request, closed after response
        yield session

class UserRepository:
    def __init__(self, session: AsyncSession):
        self.session = session
    async def get(self, user_id: int) -> User | None:
        return await self.session.get(User, user_id)

# Depends on get_db; other repositories doing the same share this request's cached session.
async def get_user_repository(db: AsyncSession = Depends(get_db)) -> UserRepository:
    return UserRepository(db)

@app.get("/users/{user_id}")
async def read_user(user_id: int, users: UserRepository = Depends(get_user_repository)):
    user = await users.get(user_id)  # endpoint never touches session plumbing
    return {"id": user.id, "email": user.email} if user else None

async def scalar_query(stmt):
    async with SessionLocal() as session:  # each concurrent task gets its own session
        return await session.scalar(stmt)

@app.get("/stats")
async def stats():
    total, max_id = await asyncio.gather(  # never share the request's AsyncSession here
        scalar_query(select(func.count()).select_from(User)), scalar_query(select(func.max(User.id))))
    return {"total": total, "max_id": max_id}

An async repository dependency built on an AsyncSession-yielding get_db, plus an endpoint that gives each asyncio.gather task its own session instead of sharing the request's; the sync variant has the same shape with def, Session, sessionmaker and a blocking driver.

Testing with dependency_overrides and transaction rollback

FastAPI resolves overrides through app.dependency_overrides, a dictionary keyed by the original dependency function object. The key must be the same get_db that endpoints pass to Depends. A wrapper or a re-imported copy will not match, and the real dependency runs silently. The replacement is also a generator: it yields the test session and leaves cleanup to the fixture. Clear the dictionary in the fixture's teardown. Otherwise an override from one test leaks into every later test that shares the app instance.

Rollback isolation works below the session. The fixture:

  1. Opens a connection from the test engine.
  2. Begins an outer transaction on that connection.
  3. Constructs a session bound to that connection.
  4. After the test, closes the session, rolls back the outer transaction and closes the connection.

Each test starts from the same database state, with no truncation or reseeding between tests.

This setup alone does not hold up once application code calls commit, which is often the case if commits live in the dependency or the service layer. SQLAlchemy documents a recipe for joining a session into an external transaction in which the session works inside a savepoint. When the code under test calls commit, only the savepoint is released, and the outer transaction stays open for the final rollback. Use that recipe rather than patching commit out. Tests should exercise the real commit path, including flush-time constraint errors.

The same mechanism replaces other dependencies. Override get_current_user to return a fixed principal, and override the dependency that provides a payment or email client to return a fake. Overriding at the dependency boundary is more reliable than monkeypatching imports. A monkeypatch must target the module where the name is looked up, which breaks when someone changes an import style. An override targets the one function FastAPI actually resolves, wherever it is imported.

When to avoid the pattern and what the alternatives are

That precision has limits, and the pattern itself has failure modes worth knowing before you adopt it everywhere.

Pool exhaustion. A request-scoped session holds a pooled connection for the whole request. If an endpoint awaits a slow external API, that connection sits idle while it waits. Under concurrency the pool drains and unrelated requests queue behind it. For these endpoints, open the session only around the actual queries.

Work that outlives the request. The exit timing of yield dependencies relative to background tasks and streaming responses has changed across FastAPI versions, so don't rely on it. Give background tasks and streamed bodies their own session.

Rollback tests are incomplete. A test that rolls back never runs a real commit. It misses deferred constraint checks that fire at commit time, and it cannot reproduce contention between connections. Keep a small suite of integration tests that commit for real.

Global overrides. dependency_overrides is mutable state on the app object. An override that leaks from one test changes the behaviour of the next, and parallel tests that share the app interfere with each other.

SQLite as a stand-in. SQLite differs from production databases in how it handles types, locking and constraint enforcement. Tests can pass on SQLite and fail in production.

Alternative Fits when Cost
Service-layer context manager Long or mixed I/O endpoints Session plumbing in every service
Middleware-managed session Uniform request handling Opens sessions for routes that don't need one
Module global + monkeypatch Scripts, legacy code Fragile across import styles
Per-test database Commit-level and concurrency tests Slower setup, needs teardown

Impact on development cost and engineering time

The main saving is that session handling lives in one place. Without a shared dependency, every endpoint opens, commits, rolls back, and closes its own session. Each copy can leak a connection or skip a rollback on error. With get_db, reviewers verify transaction behavior once. A later change, such as adding a statement timeout or moving to async, touches one function instead of every route.

Tests get cheaper for the same reason. When the override hands out a session bound to an outer transaction, each test rolls back instead of truncating and reseeding tables. Because no test patches a module global, tests don't depend on import order and can run in any sequence or across parallel workers without leaking state into each other.

The costs come early and continue afterward. You write and maintain the connection-and-savepoint fixture. You debug code that calls commit() inside the rollback wrapper. You also keep a smaller suite that commits for real, because rollback-based tests miss commit-time constraint failures, deferred checks, and isolation bugs.

Illustrative estimate (assumed numbers, not a benchmark): time saved per run = tests × (reset time per test − rollback time per test). Assume 400 tests, 50 ms to truncate and reseed, and 5 ms to roll back. That gives 400 × 45 ms = 18 s per run. Multiply by runs per day, then compare the result against the time spent building the fixture.

Decision checklist

  • More than a few endpoints touch the database.
  • Tests currently share state or must run serially.
  • You can fund a separate real-commit suite.
  • The team has agreed on where commits happen.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to fastapi dependency injection database session, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.