Running Background Tasks Without Exhausting the SQLAlchemy Pool

This guide is part of FastAPI SQLAlchemy Pool Configuration. FastAPI’s BackgroundTasks run after the response is sent, in the same process and the same event loop. They are convenient for sending an email, writing an audit record or warming a cache — and they quietly compete with request handlers for the same connection pool. A task that reuses the request’s session is worse still: the dependency that created that session has already closed it by the time the task runs.

sqlalchemy.exc.ResourceClosedError: This Session's transaction has been rolled back
sqlalchemy.exc.TimeoutError: QueuePool limit of size 10 overflow 5 reached,
connection timed out, timeout 30.00

The objective is background work that opens its own session, holds a connection only while it needs one, and cannot consume more than a defined share of the pool — with a clear threshold for when the work belongs in a queue worker instead.

Rapid incident diagnosis

Check whether tasks share the request’s session. The dependency pattern makes this easy to get wrong:

grep -rn "BackgroundTasks\|background_tasks.add_task" --include=*.py app/ | head
grep -rn "add_task(.*db\b\|add_task(.*session" --include=*.py app/ | head

Passing db or session into add_task is the bug: the session is closed when the request’s dependency exits, so the task either fails immediately or resurrects a closed session.

Measure the pool. SQLAlchemy’s pool reports its state synchronously:

p = engine.pool
print({"size": p.size(), "checked_in": p.checkedin(), "overflow": p.overflow(), "checked_out": p.checkedout()})

checkedout() at pool_size + max_overflow with request latency rising means the pool is the constraint; compare the number with the request concurrency to see whether background work accounts for the difference.

Attribute connections on the database. A separate application_name for background work makes the split visible at a glance:

SELECT application_name, state, count(*)
FROM pg_stat_activity WHERE application_name LIKE 'orders-%' GROUP BY 1, 2 ORDER BY 3 DESC;

Look for long tasks. Background work that runs for seconds — an export, a report, a fan-out of HTTP calls — holds a connection for its whole duration if it opens a session at the start and closes it at the end:

SELECT pid, state, now() - xact_start AS txn_age, left(query, 60)
FROM pg_stat_activity
WHERE application_name = 'orders-bg' AND xact_start IS NOT NULL
ORDER BY xact_start LIMIT 10;
Background tasks competing with requests for one pool Request handlers and background tasks both check out connections from the same SQLAlchemy pool, so a burst of long-running tasks leaves fewer connections for requests. Request handlers Background tasks One pool pool_size + overflow PostgreSQL the pool does not know which caller is which, so the slowest work wins unless the application gives background work its own bounded share
Background tasks share the process, the event loop and the pool with request handlers. Without a bound, a burst of tasks starves requests of connections.

Mathematical sizing or parameter formula

Background work adds a second term to pool demand:

request_in_flight   = requests_per_second × queries_per_request × mean_query_seconds
task_in_flight      = tasks_per_second × mean_task_connection_seconds
pool_size + overflow ≥ ceil( (request_in_flight + task_in_flight) ÷ 0.7 )

The second term is usually the surprise. A task that runs for 400 ms holding one connection, fired once per request at 50 requests per second, needs 50 × 0.4 = 20 connections continuously — twice the default pool.

Bound the share explicitly. A semaphore is the simplest way to cap concurrent background database work:

max_concurrent_tasks ≤ (pool_size + max_overflow) − reserved_for_requests

With a pool of 10 plus 5 overflow and 10 reserved for requests, background tasks may use 5 at a time.

Know when to move the work. If the required background share approaches the request share, the work belongs in a separate process:

move_to_worker when: task_in_flight > request_in_flight ÷ 2
                  or mean_task_seconds > 1
                  or the task must survive a deploy

A task that takes longer than a second is also a task that a rolling restart will interrupt, because background tasks do not survive the process exiting.

Hold time, not task duration, is what counts. A task that runs for five seconds but only queries at the start and the end can hold a connection for milliseconds if it opens two short sessions instead of one long one.

Pool demand from requests and background tasks Request handlers need about seven connections at peak, while background tasks holding a connection for four hundred milliseconds each add twenty, which exceeds the pool's total capacity. Connections in flight at 50 requests per second request handlers 7 background tasks 20 pool_size + max_overflow = 15 the tasks alone exceed the pool, so requests queue behind them
The pool must cover both terms. A task that holds a connection for its whole run adds far more demand than its query time suggests.

Exact remediation & configuration

Give each task its own session, created inside the task:

from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

engine = create_async_engine(
    DATABASE_URL, pool_size=10, max_overflow=5, pool_timeout=3, pool_pre_ping=True,
    connect_args={"server_settings": {"application_name": "orders-api"}},
)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db() -> AsyncSession:
    async with SessionLocal() as session:
        yield session            # request-scoped; closed when the request ends

async def write_audit(order_id: str, actor: str) -> None:
    async with SessionLocal() as session:        # the task's own session
        async with session.begin():
            await session.execute(
                insert(AuditLog).values(order_id=order_id, actor=actor))

@app.post("/orders/{order_id}/cancel")
async def cancel(order_id: str, tasks: BackgroundTasks, db: AsyncSession = Depends(get_db)):
    await db.execute(update(Order).where(Order.id == order_id).values(status="cancelled"))
    await db.commit()
    tasks.add_task(write_audit, order_id, "api")   # pass values, never the session
    return {"status": "cancelled"}

Bound the background share with a semaphore:

import asyncio

_bg_slots = asyncio.Semaphore(5)      # at most 5 background tasks holding connections

async def with_bg_slot(fn, *args, **kwargs):
    async with _bg_slots:
        await fn(*args, **kwargs)

# usage
tasks.add_task(with_bg_slot, write_audit, order_id, "api")

Tasks beyond the limit wait for a slot rather than for a connection, which keeps the pool’s queue for requests.

Keep sessions short inside long tasks. Open a session per unit of work rather than for the whole task:

async def export_orders(customer_id: str) -> None:
    last_id = 0
    while True:
        async with SessionLocal() as session:                 # short session per page
            rows = (await session.execute(
                select(Order.id, Order.total)
                .where(Order.customer_id == customer_id, Order.id > last_id)
                .order_by(Order.id).limit(500))).all()
        if not rows:
            break
        last_id = rows[-1][0]
        await upload_chunk(rows)                              # no connection held here

Use a separate engine for background work when the split needs to be enforced rather than agreed:

bg_engine = create_async_engine(
    DATABASE_URL, pool_size=4, max_overflow=0, pool_timeout=10,
    connect_args={"server_settings": {"application_name": "orders-bg"}},
)
BgSession = async_sessionmaker(bg_engine, expire_on_commit=False)

Two engines mean two pools and two budgets, but the guarantee is absolute: background work cannot take a connection a request needed. It also makes the database’s attribution exact, which is what the application_name split is for.

Move genuinely long work to a queue. FastAPI background tasks do not survive a process restart and have no retry. Anything that must complete belongs in Celery, RQ, Dramatiq or a queue consumer, where the worker’s own pool is sized for it — see Closing ORM Connections in Background Job Workers.

Choosing between a task, a worker and a transaction

The decision is usually made implicitly by whoever writes the endpoint; making it explicit avoids most pool incidents.

In the request’s transaction. Work that must be atomic with the request — writing an outbox row, updating a counter — belongs inside the same transaction. It costs nothing extra in connections because the connection is already held.

As a background task. Work that is short, best-effort and does not need to survive a restart: an audit record, a cache invalidation, a metric. The cost is a brief second connection after the response.

As a queued job. Work that is slow, must be retried, must survive deploys, or is heavy enough to need its own concurrency control: sending email, generating a report, calling a slow third party. The cost is infrastructure, and the benefit is that the web tier’s pool is untouched.

Property In the transaction Background task Queued job
Atomic with the request yes no no
Survives a restart yes (committed) no yes
Retries n/a no yes
Extra connection no yes, briefly no (worker’s pool)
Suitable duration milliseconds under ~500 ms any
Backpressure request’s own semaphore queue depth

A useful rule: if the work would be acceptable to lose when a pod is replaced, a background task is fine; otherwise it needs a queue.

Choosing where background work runs Work that must be atomic runs in the request transaction, short best-effort work runs as a background task with a bounded share, and durable or slow work runs in a queue worker with its own pool. In the transaction atomic no extra connection Background task best-effort bounded share Queue worker durable, retried its own pool Durability decides, not convenience if losing the work on a pod replacement is unacceptable, it is not a background task
The three options differ in durability and in where their connections come from. Choosing by duration alone is what puts slow work in the request tier's pool.

Validation & verification

Tasks never receive a request session. A grep in CI is enough to keep it that way:

! grep -rn "add_task([^)]*\b\(db\|session\)\b" --include=*.py app/

The background share is bounded. Under a load test that fires a task per request, the database’s session count for orders-bg must not exceed the semaphore limit or the background engine’s pool:

SELECT count(*) FROM pg_stat_activity WHERE application_name = 'orders-bg';

Requests do not queue behind tasks. Compare request p99 latency with and without background work enabled at the same request rate; the difference should be small. A large difference means the bound is too generous or the tasks hold connections too long.

No session leaks. After a load test, engine.pool.checkedout() must return to zero:

assert engine.pool.checkedout() == 0, engine.pool.status()

Tasks do not outlive the process silently. Trigger a graceful shutdown while tasks are running and confirm the shutdown waits for them or that losing them is acceptable — whichever the design intends. FastAPI waits for tasks started before shutdown began, but not for work queued after.

Frequently Asked Questions

Can a background task use the request’s session if I do not close it?
Keeping the session open past the response holds a connection for the task’s whole duration and breaks the dependency’s cleanup guarantees. Creating a new session in the task is both simpler and cheaper.
Does run_in_threadpool change the pool arithmetic?
Synchronous work offloaded to a thread pool still needs a connection while it queries, and a synchronous engine has its own pool. The arithmetic is the same; only the pool is different.
Is max_overflow the right place to absorb background work?
Overflow connections are opened under burst and closed when returned, which suits occasional tasks. Relying on overflow for steady background load means constant connection churn; size pool_size for the steady part instead.
What about asyncio.create_task instead of BackgroundTasks?
It detaches the work from the request entirely, with no framework tracking and no shutdown handling. It has the same pool implications plus a higher chance of unobserved failures; if the work matters, use a queue.