Async SQLAlchemy Session per Request in FastAPI

Key takeaways:

  • One engine for the process, one session per request, and the session arrives by injection.
  • Ten concurrent requests measured as ten distinct session objects; the handler and its nested dependencies share exactly one.
  • expire_on_commit=False is effectively mandatory: the default raises MissingGreenlet during serialization.
  • Handing the request session to a background task appears to work and can silently roll back the request's own writes.
  • The dependency owns the transaction boundary so handlers never have to.

This guide is the concrete wiring behind Async Database Sessions, and it is an application of the yield pattern from Dependency Injection Strategies. Where the transaction boundary belongs and what happens when it moves is covered separately in Transaction Management and Rollback; this page is about the scope — which session, for how long, and who else can see it.

The lifetime of a request-scoped session A timeline across one request. The dependency opens a session, the handler and nested dependencies share that same object, background tasks run while it is still open, and only then does the dependency commit and close it. One request, one session, and everything inside its scope session-1 is open nested dependency path operation response serialization background tasks open commit, then close Every box inside the frame holds the same session object. The dashed box is the one that surprises people: tasks run before teardown, so the session is still live when they do.
Request scope is wider than the handler. Anything that runs before the dependency's teardown shares the session, including background tasks.

The Problem This Solves

There are exactly two ways to get this wrong, and both are common.

Share one session across the application and you get corrupted transaction state: two concurrent requests writing into the same identity map, one request's rollback discarding another's pending changes, and errors that only appear under load. Create a session ad hoc wherever you need one and you leak connections, because nothing guarantees the close.

Request scope resolves both. One session per request, created by the framework, torn down by the framework, and reachable from anywhere in that request without being reachable from anywhere else.

Why It Happens: Two Very Different Lifetimes

The confusion comes from treating the engine and the session as the same kind of object. They are not.

The engine owns the connection pool. Creating one opens sockets, negotiates TLS and starts background housekeeping. It is a process-lifetime resource and belongs in the lifespan, created once. Creating an engine per request creates a pool per request, which is the fastest known way to exhaust a database's connection limit.

The session is a unit of work. It holds an identity map, a queue of pending changes, and — once it touches the database — one connection borrowed from the engine's pool. It is cheap to create, is not concurrency-safe, and must be closed so its connection goes back.

So: engine at startup, session per request.

The Implementation

1. Engine and factory at startup

from contextlib import asynccontextmanager

from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine


@asynccontextmanager
async def lifespan(app: FastAPI):
    engine = create_async_engine(settings.database_url, pool_size=10, max_overflow=5)
    # expire_on_commit=False is not a style preference here — see the transcript below.
    app.state.session_factory = async_sessionmaker(engine, expire_on_commit=False)
    app.state.engine = engine
    yield
    await engine.dispose()

Keeping the engine on app.state rather than at module scope means tests can build a second app with a different database without patching imports.

2. The session dependency

from collections.abc import AsyncIterator
from typing import Annotated

from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession


async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
    """One session per request, committed on success, always closed."""
    async with request.app.state.session_factory() as session:
        try:
            yield session
        except Exception:
            await session.rollback()
            raise
        else:
            await session.commit()


SessionDep = Annotated[AsyncSession, Depends(get_session)]

The async with is what guarantees the close, on every path including cancellation. The try/except/else shape — rather than a plain commit after the yield — matters enough that it has its own page.

3. Use it

@router.post("/users")
async def create_user(email: str, session: SessionDep) -> dict:
    user = User(email=email)
    session.add(user)
    await session.flush()          # emits the INSERT, populates user.id, stays in the transaction
    return {"id": user.id, "email": user.email}

flush() rather than commit(). The handler needs the generated primary key, and flush gives it that while leaving the transaction boundary where it belongs.

What "Request Scope" Actually Guarantees

The claims above are testable, so here they are tested. This app declares get_session, a second dependency that also asks for a session, and a handler that receives both.

async def audit_repo(session: SessionDep) -> dict:
    """A second dependency that also asks for the session, to show it is not a second session."""
    return {"session_id": label(session)}


@app.get("/identity")
async def identity(session: SessionDep, audit: AuditDep) -> dict:
    """Handler and nested dependency must receive the SAME session object."""
    return {
        "handler_session": label(session),
        "dependency_session": audit["session_id"],
        "same_object": label(session) == audit["session_id"],
    }

Real output from running two sequential requests against it:

$ GET /probe/identity-across-requests
200 OK
{
  "request_1_session": "session-1",
  "request_2_session": "session-2",
  "same_session_reused": false,
  "handler_and_dependency_shared_in_request_1": true
}

Two guarantees in one transcript. Within a request, the handler and the nested dependency hold the same object — FastAPI caches dependency results per request, so get_session runs once no matter how many places declare it. Across requests, the sessions are different objects. Nothing carries over.

The same holds under real concurrency rather than sequential calls:

$ GET /probe/concurrent-requests-get-distinct-sessions
200 OK
{
  "requests": 10,
  "distinct_session_objects": 10
}

Ten overlapping requests, ten separate sessions. That is the property that makes the pattern safe: there is no shared mutable state to corrupt.

And the dependency really does close it, on the ordinary success path:

$ GET /probe/session-closed-after-response
200 OK
{
  "events_in_order": [
    "dependency opened session-3",
    "dependency closed session-3"
  ]
}

The expire_on_commit Trap

expire_on_commit=False is passed in almost every FastAPI example, usually without explanation. Under async it is not an optimisation — the default is close to unusable.

With expire_on_commit=True (SQLAlchemy's default), committing marks every loaded attribute as stale. The next attribute access is supposed to quietly re-fetch it. In a synchronous application it does. Under async, that lazy refresh needs to perform I/O from a context that cannot await:

$ GET /probe/expire-on-commit
200 OK
{
  "expire_on_commit=False": {
    "read_after_commit": "probe-expire_on_commit=False@example.com",
    "error": null
  },
  "expire_on_commit=True": {
    "read_after_commit": null,
    "error": "MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s)"
  }
}

MissingGreenlet: greenlet_spawn has not been called is one of the most-searched SQLAlchemy async errors, and this is where most people meet it. The reason it is so confusing in a FastAPI app is when it fires: the dependency commits after the handler returns, and the response is serialized after that, so the exception surfaces during serialization of an object the handler returned successfully. The traceback points at Pydantic, not at your query.

Set expire_on_commit=False and attribute access after commit serves the values already in memory. The trade is that those values are a snapshot from before the commit — fine for serializing a response you just wrote, wrong if you were relying on the database to have changed them underneath you via a trigger or a default.

The Background Task Trap

This is the part worth reading even if you have wired sessions before.

It is natural to hand the session you already have to a background task. Try it, and it works — which is the problem. Here is the actual event ordering on FastAPI 0.139.2, with a task that writes through the request's session and a second that opens its own:

$ GET /probe/background-task-session
200 OK
{
  "timeline": [
    "dependency opened session-5",
    "handler returning",
    "background task wrote reused@example.com through session-5",
    "background task wrote own@example.com through its own session",
    "dependency closed session-5",
    "client has its response"
  ],
  "rows_that_survived": [
    "own@example.com",
    "reused@example.com"
  ]
}

The background tasks ran before the dependency's teardown. The session was still open, the write went through, and the dependency's commit — which had not happened yet — swept it up. Both rows survived. Every test you write will pass.

Now make the task fail, as a task calling a payment provider or a mail server eventually will:

async def failing_task_on_request_session(session: AsyncSession) -> None:
    """A task that borrows the request session and then fails, after the client has its 200."""
    session.add(User(email="from-task@example.com"))
    await session.flush()
    raise RuntimeError("receipt provider rejected the order")
$ GET /probe/failing-task-rolls-back-the-request
200 OK
{
  "no_failing_task": {
    "client_saw": "200 {'accepted': 'clean@example.com'}",
    "row_is_in_the_database": true
  },
  "with_failing_task": {
    "client_saw": "200 sent, then RuntimeError: receipt provider rejected the order",
    "row_is_in_the_database": false
  }
}

Read those two rows together. Identical handler, identical 200 response, opposite durable outcomes. In the second case the client was told the order was accepted, and the order is not in the database — the task's exception propagated out through the dependency's teardown, hit the except branch, and rolled back the request's own committed-intent work.

The client cannot detect this. It has a 200. There is no retry, no error, nothing in the response to act on. You have returned success and stored nothing.

The rule that follows is unambiguous: background work opens its own session.

async def send_receipt(order_id: int) -> None:
    """Background work owns its own unit of work, with its own commit and its own failure domain."""
    async with session_factory() as session:
        order = await session.get(Order, order_id)
        order.receipt_sent = True
        await session.commit()

Pass the task an identifier, never a live ORM object or a session. It re-loads what it needs, and its failures belong to it alone. The related question of what happens to the exception itself — spoiler: it does not reach your error handlers — is covered in When BackgroundTasks Silently Fails.

Verification

The write path is worth a test that does not trust the session under test:

async def test_the_dependency_commits(client, session_factory):
    response = await client.post("/users", params={"email": "ada@example.com"})
    assert response.status_code == 200
    # A brand-new session sees it only if the dependency really committed.
    async with session_factory() as fresh:
        assert await fresh.scalar(select(User.email).where(User.id == response.json()["id"]))

Reading back with a different session is the point. The session that wrote the row would report it from its identity map whether or not the commit happened, so asserting through it proves nothing.

$ GET /probe/commit-happens-in-the-dependency
200 OK
{
  "created": {
    "id": 1,
    "email": "ada@example.com"
  },
  "read_back_with_a_new_session": "ada@example.com"
}

Add one assertion for the background-task rule too — that no task receives an AsyncSession argument. A one-line check over your task registrations is cheaper than discovering the failure above in production. The full fixture chain for database-backed tests is in Testing with Async Database Fixtures.

Trade-offs and When Not To

One transaction per request is wrong for batch endpoints. An endpoint importing 100,000 rows inside a single request transaction holds one connection for the entire import and rolls back everything on the last row. Those want explicit, chunked transactions, not the request-scoped default.

Request scope means request lifetime. Anything living longer — a task, a websocket, a scheduled job — needs its own session from the shared factory. The factory is the reusable thing, not the session.

expire_on_commit=False serves a snapshot. If a database trigger or a server-side default modifies the row during your commit, the object in memory will not reflect it. Refresh explicitly when that matters.

Streaming responses outlive the dependency. A StreamingResponse that lazily pulls from the database is generating its body after teardown has closed the session. Materialise the rows before returning, or manage that session by hand.

FAQ

Why one session per request instead of a global session? A session holds transaction state and a connection and is not safe to share across concurrent requests. Ten overlapping requests measured as ten distinct session objects, each with its own transaction, each returning its connection to the pool when the request ended.

Does every dependency in a request get the same session? Yes, provided they all depend on the same dependency callable. FastAPI caches dependency results per request, so the handler and any nested dependency receive the identical session object. Two sequential requests get two different sessions.

Why does expire_on_commit break async serialization? With expire_on_commit=True, committing marks every loaded attribute stale, so the next attribute access triggers a refresh query. Under async that lazy I/O happens outside a greenlet context and raises MissingGreenlet rather than loading, which is why expire_on_commit=False is effectively mandatory here.

Can a background task use the request's session? It should not, even though it appears to work. On FastAPI 0.139.2 the tasks run before the dependency's teardown, so the session is still open and the write is swept into the dependency's commit. If the task then raises, the dependency rolls back the request's own writes after the client already received a 200.

Should I call commit in the dependency or in the handler? In the dependency. One transaction boundary per request keeps handlers free of transaction management and guarantees rollback on error. A commit inside a handler converts any later failure into a durable partial write.

Where should the engine be created? Once, at startup, in the lifespan context manager, stored on app.state. The engine owns the connection pool, which is a long-lived resource. Creating an engine per request creates a pool per request and exhausts the database's connection limit almost immediately.