Running background tasks with a fresh AsyncSession in FastAPI

Pass primary keys to background_tasks.add_task(), never the request's AsyncSession or ORM objects, and open a new session from your async_sessionmaker inside the task — the dependency that yielded the request session closes it as the request ends. This guide belongs to integrating SQLAlchemy async with FastAPI and Starlette.

Quick Answer

A background task runs after the response. By then the request's session is closed or closing, and objects loaded through it are detached.

Why the request session is the wrong session Five steps. The get_session dependency yields a session. The endpoint writes the order, commits, and schedules send_receipt with the session and the order object. The response is returned. The dependency cleanup closes the session, before or around the time the background task begins depending on the FastAPI version. The background task then touches order attributes or runs a query on a session that is closed or closing, producing DetachedInstanceError or IllegalStateChangeError. dependency yield session request scope begins endpoint commit; add_task(send_receipt, session, order) hands over the session response sent to the client request scope is ending dependency cleanup session.close() timing relative to the task varies background task order.customer.email → error The session belongs to the request. Anything that runs after the response needs its own.

Before — the request session and an ORM object handed to the task:

from fastapi import BackgroundTasks, Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession

from shop.db import get_session
from shop.mail import send_email
from shop.models import Order

app = FastAPI()


async def send_receipt(session: AsyncSession, order: Order) -> None:
    # order.customer is not loaded; the session that could load it is gone.
    await send_email(order.customer.email, f"Receipt for order {order.id}")
    order.receipt_sent = True
    await session.commit()


@app.post("/orders")
async def create_order(
    payload: dict,
    background_tasks: BackgroundTasks,
    session: AsyncSession = Depends(get_session),
):
    order = Order(**payload)
    session.add(order)
    await session.commit()
    background_tasks.add_task(send_receipt, session, order)
    return {"id": order.id}
# sqlalchemy.orm.exc.DetachedInstanceError: Parent instance <Order at 0x7f...> is not bound
# to a Session; lazy load operation of attribute 'customer' cannot proceed

After — an ID in, a fresh session inside:

from fastapi import BackgroundTasks, Depends, FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from shop.db import Session, get_session
from shop.mail import send_email
from shop.models import Order

app = FastAPI()


async def send_receipt(order_id: int) -> None:
    async with Session() as session:
        order = await session.scalar(
            select(Order).where(Order.id == order_id).options(selectinload(Order.customer))
        )
        if order is None or order.receipt_sent:
            return
        await send_email(order.customer.email, f"Receipt for order {order.id}")
        order.receipt_sent = True
        await session.commit()


@app.post("/orders")
async def create_order(
    payload: dict,
    background_tasks: BackgroundTasks,
    session: AsyncSession = Depends(get_session),
):
    order = Order(**payload)
    session.add(order)
    await session.commit()
    background_tasks.add_task(send_receipt, order.id)
    return {"id": order.id}

Session here is the application's async_sessionmaker, the same factory get_session uses. The receipt_sent check makes the task safe to run twice, which matters as soon as it is retried or moved to a queue.

Execution Context & Async Workflow Integration

FastAPI runs BackgroundTasks in the same process and on the same event loop as the request, after the response has been produced. They are a Starlette feature: the response object carries the tasks, and Starlette awaits them once the body has been sent. That makes them cheap — no queue, no worker — and it places them outside the request's scope.

Pass a key, not a session Left: add_task(send_receipt, session, order) passes a session scoped to the request and an object bound to it; both are unusable once the request ends. Right: add_task(send_receipt, order.id) passes an integer; the task opens its own session from the async_sessionmaker, reloads the order with the relationships it needs, and commits its own work. add_task(send_receipt, session, order) session: owned by the request order: bound to that session DetachedInstanceError IllegalStateChangeError add_task(send_receipt, order.id) an int survives any boundary task: async with Session() as s reload with selectinload() commit its own changes The reload costs one query. It also means the task sees committed data, not the request's view.

The request session is scoped by a dependency with yield:

from collections.abc import AsyncIterator

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

engine = create_async_engine("postgresql+asyncpg://shop:secret@db/shop", pool_size=10)
Session = async_sessionmaker(engine, expire_on_commit=False)


async def get_session() -> AsyncIterator[AsyncSession]:
    async with Session() as session:
        yield session

When the async with block exits, the session closes and its connection returns to the pool. Exactly when that happens relative to background tasks has changed between FastAPI releases — some versions run dependency cleanup before the response is sent, others after — and code that depends on either ordering breaks on upgrade. Treat the request session as unavailable to anything scheduled with add_task(), whatever the version.

The failures that result depend on what the task does first. Touching an unloaded attribute on a passed object raises DetachedInstanceError, or under async MissingGreenlet if the object is still nominally attached. Executing on a session that the dependency is closing at the same moment raises IllegalStateChangeError: Method 'close()' can't be called here. Executing on a session that has already closed can even appear to work, because a closed AsyncSession in 2.0 starts a new transaction on next use — and then nothing closes it, and the connection it checked out is returned only when the garbage collector finds the session, with the warning covered in fixing garbage collector non-checked-in connection warnings.

A fresh session inside the task avoids all three, and it has a correctness benefit too: the task reloads committed data. If the request's transaction had rolled back, the reload finds nothing and the task exits instead of emailing a receipt for an order that does not exist. The broader rule — one session per unit of concurrent work — is the subject of running concurrent queries with AsyncSession.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
DetachedInstanceError: Parent instance <Order> is not bound to a Session; lazy load operation of attribute 'customer' cannot proceedAn ORM object from the request was passed to the task.Pass order.id; reload with selectinload() inside the task.
IllegalStateChangeError: Method 'close()' can't be called hereThe task used the request session while the dependency was closing it.Open a new session in the task.
MissingGreenlet: greenlet_spawn has not been calledLazy load of an expired attribute on a passed object.Reload inside the task with the relationships it needs.
The garbage collector is trying to clean up non-checked-in connectionThe task used a closed request session, which silently began a new transaction nobody closed.async with Session() in the task.
Task runs but the order does not existThe request transaction rolled back after scheduling.Schedule only after a successful commit; reload and check for None.
Exceptions in the task never reach error trackingStarlette logs background task exceptions, but the response is already sent.Wrap the task body and report explicitly.
Where should the work run? Three tiles. BackgroundTasks: runs in the same process after the response, lost if the process restarts, fine for seconds-long best-effort work such as a receipt email. An asyncio task created in the lifespan: in-process, needs its own tracking and shutdown handling. An external queue such as arq or Celery: survives restarts, retries, and scales separately, needed for anything that must happen. BackgroundTasks same process, after response lost on restart best-effort, seconds lifespan asyncio task same process, long-lived you own shutdown polling, cache warming job queue (arq, Celery) separate workers retries, durability work that must happen Every option needs its own session. Only the queue survives a deploy in the middle of the work.

The last row is operational rather than a SQLAlchemy error, and it is the one that hides failures longest. A background task that raises after the response has no client to report to. Wrap task bodies with explicit reporting:

import functools
import logging
from collections.abc import Awaitable, Callable
from typing import ParamSpec

log = logging.getLogger("background")
P = ParamSpec("P")


def reported(func: Callable[P, Awaitable[None]]) -> Callable[P, Awaitable[None]]:
    @functools.wraps(func)
    async def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
        try:
            await func(*args, **kwargs)
        except Exception:
            log.exception("background task failed", extra={"task": func.__name__})
            raise
    return wrapper


@reported
async def send_receipt(order_id: int) -> None:
    ...

Scheduling after commit is the other rule worth enforcing. add_task() before await session.commit() schedules work for a transaction that may still fail; the reload-and-check pattern guards against it, but ordering the calls correctly avoids the question.

Advanced: Keeping Background Work Out of the Request Pool

Every background task that opens a session draws a connection from the same pool as the requests. A burst of requests that each schedule a task doubles connection demand shortly after the burst, exactly when the pool is already busy, and requests start waiting on pool_timeout for connections held by work nobody is waiting for.

Background tasks share the request pool Bar chart. Requests alone peak at 12 connections. Requests plus uncapped background tasks peak at 27, beyond the pool of 15, so requests wait. Requests plus background tasks limited by a semaphore of 3 peak at 15. requests only peak 12 of 15 requests + uncapped background tasks peak 27 — requests queue for connections requests + tasks capped at 3 peak 15 — at the limit, never over Illustrative burst. A separate, small engine for background work isolates it completely.

Two fixes, which combine well. Cap concurrent background database work with a semaphore, so tasks queue in memory rather than in the pool:

import asyncio

_background_db = asyncio.Semaphore(3)


async def send_receipt(order_id: int) -> None:
    async with _background_db:
        async with Session() as session:
            ...

Or give background work its own small engine, so it cannot take connections from requests at all:

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

background_engine = create_async_engine(
    "postgresql+asyncpg://shop:secret@db/shop",
    pool_size=3,
    max_overflow=0,
    pool_timeout=60,
    connect_args={"server_settings": {"application_name": "shop-api-background"}},
)
BackgroundSession = async_sessionmaker(background_engine, expire_on_commit=False)

A separate engine also makes background load visible in pg_stat_activity under its own application_name, and lets you give it a longer pool_timeout — a receipt that waits a minute is fine; a request that waits a minute is an outage. Dispose both engines in the lifespan shutdown, after in-flight background tasks have had a chance to finish, as described in setting up an async engine from scratch.

Know when to stop using BackgroundTasks altogether. They run in the web process, so a deploy or crash between the response and the task loses the work, and there are no retries. For anything that must happen — a payment capture, an inventory adjustment, a webhook to a partner — use a job queue. arq keeps the async model and the same session factory; Celery is covered in using SQLAlchemy async with Celery task workers. The rule about sessions is unchanged in every case: the task receives identifiers and opens its own session.

Testing Background Tasks That Use the Database

Background tasks are easy to leave untested, because an endpoint test passes as soon as the response comes back. Two kinds of test catch the bugs in this guide.

Two tests worth writing Two bands. The task test calls send_receipt with an order id against a test database and asserts both the database change and idempotence on a second call. The endpoint test posts to the endpoint, lets the background task run, and asserts its effect, which fails if the task relied on the request session or an unloaded relationship. A note: a task opening its own connection cannot see rows inside a per-test rollback transaction unless the factory is bound to the test connection. the task, directly await send_receipt(order_id); assert receipt_sent and one email — then call it twice the endpoint, end to end POST /orders; the task runs before the client returns; assert its effect in the database watch the test transaction a fresh session opens a fresh connection that cannot see uncommitted fixture rows

The first tests the task function directly, as an ordinary coroutine with an ID. Because the task opens its own session from the factory, the test needs that factory to point at the test database — which is the strongest argument for importing Session from one module rather than constructing sessions ad hoc:

import pytest
from sqlalchemy import select

from shop import background
from shop.models import Order


@pytest.mark.asyncio
async def test_send_receipt_marks_order(session_factory, order_factory, outbox):
    order_id = await order_factory(receipt_sent=False)

    await background.send_receipt(order_id)

    async with session_factory() as session:
        order = await session.scalar(select(Order).where(Order.id == order_id))
    assert order.receipt_sent is True
    assert len(outbox) == 1


@pytest.mark.asyncio
async def test_send_receipt_is_idempotent(order_factory, outbox):
    order_id = await order_factory(receipt_sent=False)
    await background.send_receipt(order_id)
    await background.send_receipt(order_id)
    assert len(outbox) == 1

The second is an endpoint test that proves the task runs after the request's session is gone. With httpx.AsyncClient and ASGITransport, background tasks run before the client call returns, so the assertion after the call sees their effect. The test that matters is the one that would have failed with the original bug — the task reading a relationship the endpoint never loaded.

One caveat for suites that roll back a single outer transaction per test, as in rolling back database state between async tests: a task that opens its own session from the application factory opens its own connection, outside the test's transaction, and will not see the test's uncommitted rows. Either bind the factory to the test connection during the test, or commit fixture data and clean up by truncation for this group of tests.

Frequently Asked Questions

Can I pass the request AsyncSession to a background task?

No. The dependency that created it closes it as the request ends, and depending on the FastAPI version that happens before or while the task runs. Pass identifiers and open a new session in the task.

Why does the error only happen sometimes?

Because the timing between dependency cleanup and the task varies, and because objects with fully loaded attributes can be read after detachment. The first unloaded attribute or the first query exposes the problem.

Should background tasks use the same engine as requests?

They can, with a semaphore limiting concurrent background database work. A small separate engine is better when background load is significant, because it cannot starve requests and shows up separately in database monitoring.

When should I use a job queue instead of BackgroundTasks?

Whenever losing the work on a deploy or crash is unacceptable, or the work needs retries, scheduling or its own scaling. BackgroundTasks is for short, best-effort follow-ups.