Files
prompts/docs/skills/async-fastapi-sqlmodel/references/session.md
T
2026-07-30 20:51:16 -05:00

17 KiB

Async SQLAlchemy Session Management

!!! info "Primary sources" - Python functools.cache - Python asynccontextmanager - SQLAlchemy asyncio extension - SQLAlchemy session basics - FastAPI dependencies with yield


Purpose

Define one canonical session model for FastAPI + SQLAlchemy asyncio:

  • configure one shared session factory,
  • create one AsyncSession per request or per unit-of-work,
  • never share one AsyncSession across concurrent tasks.

Scope and Non-Goals

  • In scope: session factory creation, FastAPI dependency wiring, request/task scoping, transaction demarcation.
  • Out of scope: ORM model design, query optimization strategy, schema migration tooling.

Rules

  • Create one cached async_sessionmaker per app-owned AsyncEngine.
  • Let repositories resolve the cached maker by database URL.
  • Use a fresh AsyncSession for each request or explicit unit-of-work.
  • Pass an AsyncSession directly to data-access functions.
  • Borrow a caller-provided session without closing or committing it.
  • Do not share AsyncSession across asyncio.gather() or parallel tasks.
  • Prefer direct dependency injection over global scoped-session patterns in new code.
  • Use explicit transaction boundaries (async with session.begin():) for writes.
  • When a use case accepts an optional session, borrow only an active caller-owned transaction or own the complete session-and-transaction scope.

Sessions and Transactions

A session and a transaction solve related but different problems:

Concept Responsibility Typical lifetime
AsyncSession Provides the ORM workspace: executes queries, tracks loaded and changed objects in its identity map, and flushes pending changes. It also coordinates access to a database connection. One request, task, or explicit unit of work.
Transaction Defines the atomic database boundary: all work inside it commits together on success or rolls back together on failure. One complete operation that must have a single outcome.

A transaction belongs to a session; it is not an alternative to one. The session is the interface used by application and data-access code, while the transaction determines when that work becomes permanent. A session may coordinate sequential transactions during its lifetime, although short-lived application scopes commonly use one session for one transaction.

Use a session without a helper-owned commit boundary for independent reads or lower-level functions that must participate in whatever transaction their caller controls:

async with session_factory() as session:
    item = await find_item(session, item_id)

Use an explicit transaction for writes, read-modify-write operations, or several statements that must succeed or fail as one unit:

async with session_factory.begin() as session:
    order = await create_order(session, order_data)
    await reserve_inventory(session, order)

SQLAlchemy sessions use autobegin, so the first database operation normally starts a transaction even for a read. Therefore, “session-only” means that the surrounding helper owns only session lifetime and does not promise to commit; it does not mean that no database transaction exists. Closing such a session releases its resources and rolls back any unfinished transaction. An explicit begin() is valuable when application code must make the atomic boundary and commit ownership visible.

For most read-only operations, a session context is sufficient. Use an explicit transaction for reads when they need a defined consistency boundary, participate in a larger atomic operation, or use locking such as SELECT ... FOR UPDATE.


Session Factory Mechanics

An async_sessionmaker[AsyncSession] is a reusable configuration object and callable session producer. It stores how sessions should be created, including the engine binding and options such as expire_on_commit=False. It is not itself a session, connection, or transaction, and calling it does not make a shared global AsyncSession.

Cache it by the application-owned engine so repeated composition calls return the same maker:

from functools import cache

from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession

from .engine import dispose_engine
from .engine import get_engine


@cache
def get_session_factory(database_url: str) -> async_sessionmaker[AsyncSession]:
    return async_sessionmaker(
        bind=get_engine(database_url),
        class_=AsyncSession,
        expire_on_commit=False,
    )


async def dispose_session_factory(database_url: str) -> None:
    get_session_factory.cache_clear()
    await dispose_engine(database_url)

functools.cache caches by argument equality and requires hashable arguments. The database URL is an explicit string key shared with the cached engine factory. The cache retains the returned maker until get_session_factory.cache_clear() runs. Cache the synchronous maker function, never an async function and never a produced AsyncSession.

Each call to session_factory() creates a distinct AsyncSession. The caller that invokes the factory owns that session lifetime and must close it, normally with async with:

async with session_factory() as session:
    ...

The factory can be shared across requests and tasks. Sessions produced by it cannot be shared across concurrent tasks.

An async_sessionmaker has no connection pool or async dispose() method of its own. dispose_session_factory() means "invalidate the cached maker, then dispose its engine." Clearing the maker first ensures no subsequent composition call can retrieve a maker bound to the engine being shut down.

Use the helper when shutting down or replacing the database resources:

await dispose_session_factory(database_url)

Otherwise, a later call can return a maker that still references the old engine object. This matters in lifespan tests, application restarts within one process, and test suites that replace engines.


Optional Session Ownership

A small asynccontextmanager can make repository methods composable. It borrows an existing session when supplied; otherwise it creates and closes one from a supplied factory:

from collections.abc import AsyncGeneratorr
from contextlib import asynccontextmanager


@asynccontextmanager
async def session_scope(
    *,
    database_url: str,
    session: AsyncSession | None = None,
) -> AsyncGenerator[AsyncSession]:
    if session is not None:
        yield session
        return

    async with get_session_factory(database_url)() as owned_session:
        yield owned_session

The branch is intentionally explicit. Python's nullcontext can express the same borrow-or-own idea, but the branch keeps ownership and typing obvious.

This helper manages session lifetime only:

  • It does not close, commit, or roll back a supplied session; the caller owns it.
  • It closes a session that it creates. Closing releases resources and rolls back an unfinished transaction; it does not commit.
  • It does not start a transaction. Put session.begin() at the use-case boundary.
  • A supplied session wins; the cached factory is not resolved.
  • Otherwise, database_url selects the cached factory returned by get_session_factory().

Do not turn this into an implicit unit-of-work helper that sometimes commits. Whether work joins an existing transaction or creates a new one must remain visible to the caller.


Optional Transaction Ownership

Use a separate context manager when a service or use-case function must support both a caller-owned transaction and a standalone transaction. A supplied session must already be inside a transaction; otherwise the helper creates a session and transaction together with async_sessionmaker.begin():

@asynccontextmanager
async def transaction_scope(
    *,
    database_url: str,
    session: AsyncSession | None = None,
) -> AsyncGenerator[AsyncSession]:
    if session is not None:
        if not session.in_transaction():
            raise RuntimeError("A supplied session must have an active transaction")
        yield session
        return

    session_factory = get_session_factory(database_url)
    async with session_factory.begin() as owned_session:
        yield owned_session

Here, begin() is intentionally called on the async_sessionmaker, not on an existing AsyncSession. The related APIs have different ownership semantics:

  • session_factory() creates a session whose lifetime the surrounding code must manage; it does not commit automatically.
  • session_factory.begin() creates a new session and transaction together, commits on successful exit or rolls back on exceptional exit, and then closes the session.
  • session.begin() manages a transaction on an existing session but does not own or close that session.

The factory form is equivalent in ownership terms to creating a session and then entering that session's transaction:

async with session_factory() as owned_session:
    async with owned_session.begin():
        yield owned_session

This helper makes transaction ownership follow the same explicit borrow-or-own mechanics as session ownership:

  • A supplied session and its active transaction remain caller-owned. The helper does not commit, roll back, or close them.
  • Without a supplied session, the helper owns the session and transaction. Successful exit commits; exceptional exit rolls back; either path closes the session.
  • Use this helper only at a complete operation, service, or use-case boundary. A public CRUD function or repository method may be such a boundary when its optional-session contract explicitly states that omitting the session owns and commits one transaction. Never use it inside a lower-level session-required helper.
  • Do not silently begin a transaction on a supplied session. That would make commit ownership depend on hidden helper behavior.

Callers that supply a session make their ownership visible with an outer transaction:

async with session_factory() as session:
    async with session.begin():
        await run_use_case(..., session=session)

Standalone callers omit the session and let the use case own the complete unit of work:

await run_use_case(...)

Repository and Function Boundaries

Pass the database URL to repository constructors. The repository stores repeatable database configuration, not mutable session state, and session_scope() resolves the cached factory when a standalone operation needs a session:

from sqlalchemy import select
from sqlmodel.ext.asyncio.session import AsyncSession


async def find_item(session: AsyncSession, item_id: int) -> Item | None:
    statement = select(Item).where(Item.id == item_id)
    return await session.scalar(statement)


class ItemRepository:
    def __init__(self, database_url: str) -> None:
        self.database_url = database_url

    async def find(
        self,
        item_id: int,
        *,
        session: AsyncSession | None = None,
    ) -> Item | None:
        async with session_scope(
            database_url=self.database_url,
            session=session,
        ) as active_session:
            return await find_item(active_session, item_id)

This split gives each layer one job:

  • The repository object identifies its database configuration and creates a session only for a standalone call.
  • Standalone calls reuse the cached factory selected by database URL.
  • A caller can pass a session to join an existing unit of work; the repository borrows it.
  • The access function owns only the query and requires an existing AsyncSession.
  • Application wiring supplies the production factory.
  • Tests can use a test database URL or call find_item() with a transaction-scoped test session.

When several repository operations must share one transaction, pass the same session through each call. Put the transaction at the use-case boundary:

async with session_factory() as session:
    async with session.begin():
        item = await repository.find(item_id, session=session)
        await update_item(session, item, changes)

This preserves atomicity without making repository objects hold mutable AsyncSession instances across calls.


Canonical FastAPI Dependency Pattern

from collections.abc import AsyncGenerator

from fastapi import Depends
from fastapi import Request
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession


type SessionFactory = async_sessionmaker[AsyncSession]


def resolve_session_factory(request: Request) -> SessionFactory:
    return get_session_factory(request.app.state.settings.database_url)


async def get_db_session(
    session_factory: SessionFactory = Depends(resolve_session_factory),
) -> AsyncGenerator[AsyncSession]:
    async with session_factory() as session:
        yield session

Route usage:

from fastapi import APIRouter, Depends
from sqlmodel.ext.asyncio.session import AsyncSession

from .session import get_db_session

router = APIRouter()


@router.post("/items")
async def create_item(session: AsyncSession = Depends(get_db_session)) -> dict:
    async with session.begin():
        # write operations here
        ...
    return {"status": "ok"}

Configuration Guidance

  • expire_on_commit=False is commonly preferred in asyncio applications to reduce accidental post-commit reload behavior.
  • AsyncSession.refresh() is preferred over broad expiration patterns when state refresh is needed.
  • async_sessionmaker.begin() is a concise option when one scope must create a session, begin a transaction, commit on success, roll back on failure, and close. Do not use it when borrowing a caller's session.

SQLModel Alignment

  • Use SQLModel as the default model and statement layer while keeping the same session ownership model: one async_sessionmaker, one AsyncSession per request/unit-of-work.
  • SQLModel does not replace SQLAlchemy async lifecycle primitives; it provides model declaration, validation, and typing ergonomics on top of them.
  • Do not mix ad hoc session construction with the canonical async dependency.

Concurrency Rules

  • One session per concurrent task.
  • If work fans out into parallel tasks, each task receives its own AsyncSession.
  • Pass sessions explicitly to service functions; avoid mutable global session state.

Anti-Patterns

  • A singleton/global AsyncSession reused across requests.
  • Sharing one AsyncSession across parallel tasks.
  • Passing an application-global AsyncSession to a repository constructor.
  • Caching an AsyncSession instead of caching async_sessionmaker.
  • Leaving a cached maker pointing at a disposed or replaced engine.
  • Calling the session factory inside low-level access functions such as find_item().
  • Hidden session creation in lower access functions with no caller control.
  • Closing or committing a session supplied by the caller.
  • Starting a new transaction inside a helper that may receive a session already in a transaction.
  • Silently starting or committing a transaction on a supplied session.
  • Mixing commit/rollback ownership across layers without a declared boundary.

Operational Checks

  • Exactly one cached async_sessionmaker exists per application engine.
  • Session factory caches are cleared before their engines are disposed or replaced.
  • Request handlers receive sessions from one canonical dependency.
  • No code path creates AsyncSession in module import side effects.
  • Background jobs and API handlers each create task-local sessions.

Testing Checks

  • Repository constructors accept a test database URL without FastAPI startup.
  • Session-taking access functions accept a transaction-scoped test session directly.
  • Optional-session tests verify that borrowed sessions remain open and created sessions close.
  • Optional-session tests verify that neither path commits implicitly.
  • Optional-transaction tests verify supplied sessions require an active transaction and remain caller-owned.
  • Optional-transaction tests verify owned transactions commit on success, roll back on failure, and close their sessions.
  • Cache tests clear get_session_factory before and after replacing engines.
  • Dependency override exists for the FastAPI session factory.
  • Rollback behavior is verified for failed write units.
  • Parallel-task tests verify no shared AsyncSession instances.
  • Lifespan tests confirm session factory is initialized and teardown-safe.