Fixing asyncpg Connection Pool Exhaustion in FastAPI

Key takeaways:

  • The error names your limits back to you: QueuePool limit of size 2 overflow 0 reached, connection timed out, timeout 2.50.
  • Exhaustion is either a leak or a sizing problem, and pool.checkedout() at idle tells you which in one glance.
  • A leak is silent where it happens: two leaking requests returned 200, and the third found an empty pool and timed out.
  • Under-capacity does not fail immediately — it queues, then fails. Four concurrent requests through two slots all succeeded; ten produced four timeouts.
  • An upstream call inside a transaction held its connection for 0.15s; moving it out dropped that to effectively zero at identical request latency.

This is the failure-mode deep dive for Async Database Sessions. If you have not yet wired the request-scoped session, start with Async SQLAlchemy Session per Request — most exhaustion bugs are that pattern implemented not quite correctly.

A note on what is measured here. asyncpg and PostgreSQL are not available in this page's verification environment, so nothing below is presented as asyncpg output. Every transcript comes from SQLAlchemy's AsyncAdaptedQueuePool driving aiosqlite. That is deliberate rather than a compromise: when you use SQLAlchemy's async engine, SQLAlchemy's pool is the pool, and pool_size, max_overflow, pool_timeout and checkedout() behave identically whichever async driver sits underneath. The asyncpg-specific details in the final sections are described rather than executed, and are marked as such.

How requests queue behind a bounded connection pool A pool of two persistent connections plus one overflow slot serves three requests at a time. Further requests wait in a queue, and any request that waits longer than pool_timeout fails with a QueuePool timeout error. Capacity is pool_size + max_overflow, and no more in flight request 1 request 2 request 3 requests 4-15 wait the pool pool_size slot 1 pool_size slot 2 overflow slot database max_connections is shared by every worker, every job runner and migrations Waiting is not failing. A request only errors once it has waited longer than pool_timeout, which is why exhaustion first appears as latency. A leaked connection never returns to a slot at all.
Two slots, ten requests. The pool converts excess concurrency into latency until the timeout turns it into errors.

The Problem This Solves

Under load, requests begin failing with connection-acquire timeouts while the database itself is idle. CPU is low, query times are normal, and the database is not near its connection limit. Restarting the service fixes it for a while.

That combination — application-side timeouts with a healthy database — means the bottleneck is your own pool. Either connections are being handed out and never returned (a leak), or there are simply not enough of them for the concurrency you are serving (a sizing problem). These have completely different fixes and it is worth spending two minutes distinguishing them before changing anything.

Read the Error First

The exception is more informative than most people give it credit for. Holding every slot in a pool configured with pool_size=2, max_overflow=0, pool_timeout=2.5 and then asking for one more connection produces:

$ GET /probe/the-timeout-error
200 OK
{
  "pool_state_while_full": {
    "size": 2,
    "checked_out": 2,
    "overflow": 0
  },
  "waited_s": 2.5,
  "pool_timeout_s": 2.5,
  "outcome": "TimeoutError: QueuePool limit of size 2 overflow 0 reached, connection timed out, timeout 2.50 (Background on this error at: https://sqlalche.me/e/20/3o7r)",
  "pool_state_after_release": {
    "size": 2,
    "checked_out": 0,
    "overflow": 0
  }
}

QueuePool limit of size 2 overflow 0 reached, connection timed out, timeout 2.50 states your three configured numbers back to you. Check them against your create_async_engine call — if they do not match what you think you configured, you have found the bug already, and it is usually a second engine created somewhere you forgot about.

Note waited_s: 2.5. The request did not fail on arrival; it waited the full pool_timeout first. That is the behaviour that makes exhaustion present as a latency problem before it presents as an error, and it is why the p99 graph moves days before the alerts do.

Diagnosis: Does checkedout() Return to Zero?

This is the single most useful check, and it takes one line. A correctly scoped application returns every connection between requests, so at idle pool.checkedout() is 0. A leaking one has a baseline that only ever climbs.

The two dependency shapes, side by side:

async def get_session() -> AsyncIterator[AsyncSession]:
    """Yield-scoped: the context manager returns the connection whatever happens."""
    async with Session() as session:
        yield session


async def get_leaked_session() -> AsyncSession:
    """The bug: a plain `return` dependency. Nothing ever closes this session."""
    session = Session()
    await session.connection()
    return session

The difference is yield versus return, and it is worth being precise about why it matters. FastAPI treats a yield dependency as a context: it runs the code after the yield during teardown, which for async with means closing the session and returning its connection. A dependency that returns has no teardown phase at all. The session it produced is simply abandoned, and its connection stays checked out until the garbage collector eventually gets to it — which under load is far too late, if it happens at all.

Three requests through each:

$ GET /probe/leak-vs-yield-scope
200 OK
{
  "checked_out_at_baseline": 0,
  "checked_out_after_3_yield_scoped_requests": 0,
  "leaky_request_outcomes": [
    "200",
    "200",
    "TimeoutError: QueuePool limit of size 2 overflow 0 reached, connection timed out, timeout 2.50 (Background on this error at: https://sqlalche.me/e/20/3o7r)"
  ],
  "checked_out_after_3_leaky_requests": 2,
  "checked_out_after_manual_cleanup": 0
}

The yield-scoped requests returned everything: baseline 0, still 0 afterwards. The leaking ones tell the whole story in three lines. The first two returned 200 — perfectly successful responses — while quietly consuming both of the pool's two connections. The third did nothing wrong and got the timeout, because by then there was nothing left to give it.

That is the diagnostic argument in miniature. A connection leak produces no errors at the point of the leak. The requests that cause the damage succeed, and the request that fails is an innocent bystander — which is why the traceback always points somewhere unhelpful. Note too that checked_out sat at 2 and stayed there: with the pool drained, the service was not slow, it was finished, and only a restart would have cleared it.

So: export pool.checkedout() as a gauge and alert on its idle floor, not its peak. A floor that ratchets upward after each deploy is a leak, and you will see it days before it takes the service down.

@app.get("/internal/pool")
async def pool_stats(request: Request) -> dict:
    pool = request.app.state.engine.pool
    return {"size": pool.size(), "checked_out": pool.checkedout(), "overflow": pool.overflow()}

Sizing: Exhaustion Is Latency Before It Is an Error

If checkedout() does return to zero, you do not have a leak — you have more concurrency than capacity. That behaves quite differently, and understanding the difference stops people from "fixing" a leak by raising pool_size.

Four concurrent requests, each holding a connection for one second, through a pool with two slots:

$ GET /probe/over-the-ceiling
200 OK
{
  "concurrent_requests": 4,
  "status_counts": {
    "200": 4
  },
  "waves_of_the_pool": 2,
  "capacity": 2,
  "pool_timeout_s": 2.5
}

Every one succeeded. Four requests through two slots ran as two sequential waves. Nobody errored, because no individual request waited longer than the 2.5s timeout. What the users experienced was a request that took three times longer than it should have.

Push the same pool harder:

$ GET /probe/until-it-breaks
200 OK
{
  "concurrent_requests": 10,
  "status_counts": {
    "200": 6,
    "TimeoutError": 4
  },
  "capacity": 2,
  "pool_timeout_s": 2.5,
  "waves_needed_to_serve_all": 5
}

Ten requests: six served, four timed out. The queue for the fourth wave onwards was longer than pool_timeout, so those requests gave up waiting.

The shape of that failure is worth internalising. It is not gradual degradation — it is a cliff. Everything is fine, slightly slow, slightly slower, and then a specific fraction of traffic starts erroring while the rest is unaffected. That partial-failure signature, where a service returns a mix of fast 200s and timeout 500s, is nearly diagnostic of a saturated pool.

The Fix That Is Usually Right: Shorter Checkouts

Before raising pool_size, look at how long each request holds its connection. Capacity is throughput divided by hold time, so halving the hold time doubles the pool's effective capacity for free — and unlike raising the pool size, it costs the database nothing.

The classic offender is an external call inside a transaction:

# Bad: the connection is checked out for the entire upstream call.
async with session.begin():
    order = await session.get(Order, order_id)
    await charge_external_api(order)          # 150ms holding a database connection
    order.status = "charged"

# Good: the upstream call happens with nothing checked out.
order_data = await load_order(order_id)
result = await charge_external_api(order_data)
async with session.begin():
    order = await session.get(Order, order_id)
    order.status = "charged"

Measured directly:

$ GET /probe/holding-the-connection
200 OK
{
  "upstream_inside_the_transaction": {
    "held_s": 0.15
  },
  "upstream_outside_the_transaction": {
    "held_s": 0.0,
    "request_still_takes_s": 0.15
  }
}

Look at request_still_takes_s: 0.15. The request takes exactly as long either way — the user waits the same 150ms for the upstream call regardless. The only thing that changed is that in the second shape, the connection was not checked out during it: 0.15s of hold time became effectively zero.

That is the whole trade. You have not made anything faster; you have stopped one slow request from consuming a scarce shared resource while it waits on something unrelated. Under concurrency, that is the difference between the pool being the bottleneck and the pool being irrelevant.

The cost is that the operation is no longer atomic — the row is read before the charge and updated after, so it can change in between. Handle that with an optimistic version check or a conditional update, not by putting the network call back inside the transaction.

Sizing the Pool Deliberately

When hold time is already short and you genuinely need more capacity, size against the database rather than against a guess.

engine = create_async_engine(
    settings.database_url,
    pool_size=10,        # persistent connections kept open per worker
    max_overflow=5,      # extra connections opened under burst, closed when returned
    pool_timeout=10,     # seconds to wait before raising rather than hanging
    pool_pre_ping=True,  # cheaply validate a connection before handing it out
)

The arithmetic that has to hold:

(pool_size + max_overflow) × web workers
  + (pool_size + max_overflow) × background workers
  + migration tooling and admin sessions
  < database max_connections, with headroom

Four web workers at 15 each is 60. Two job runners at 15 each is another 30. That is 90 against a default PostgreSQL max_connections of 100, leaving ten for migrations, your psql session and the monitoring agent — which is already too tight. Deploys are where this bites, because during a rolling restart old and new processes are both holding connections.

Two specifics worth setting deliberately:

pool_timeout should be short. A long timeout converts a capacity problem into hung requests that pile up behind an already-full pool. A short one fails fast, surfaces the problem in your error rate, and lets a load balancer shed traffic. Ten seconds is a reasonable ceiling; the default of 30 is usually too patient.

pool_pre_ping=True is worth the round trip when anything between you and the database can close idle connections — a proxy, a failover, a cloud database's idle timeout. Without it, the first query on a stale connection fails with a confusing driver error rather than transparently reconnecting.

The asyncpg Specifics

The following is described rather than executed, since asyncpg is not installed in this page's verification environment.

If you are using SQLAlchemy's async engine with the asyncpg driver, everything above applies unchanged and asyncpg's own pooling is not in play. SQLAlchemy's AsyncAdaptedQueuePool manages the connections; asyncpg supplies individual ones. Configure pool_size and max_overflow, and ignore asyncpg's min_size and max_size — passing them via connect_args will not do what you expect.

If you are using asyncpg's create_pool directly, the model differs in ways that matter. min_size and max_size replace pool_size and max_overflow, and there is no overflow concept — max_size is a hard ceiling. Acquisition waits indefinitely unless you pass a timeout to pool.acquire(), so a leak manifests as hung requests rather than as an error, which is meaningfully harder to diagnose. Wrap acquisition in asyncio.timeout() if you take this route.

A PostgreSQL-specific consideration with no SQLite analogue: an idle-in-transaction connection is worse than an idle one. PostgreSQL exposes this in pg_stat_activity.state, and a growing count of idle in transaction rows is the server-side view of exactly the mistake in the previous section. It also blocks vacuum. Query it directly when investigating:

SELECT state, count(*) FROM pg_stat_activity GROUP BY state;

Finally, if your connection count is bounded by the database rather than by your application's needs, a server-side pooler such as PgBouncer in transaction mode lets many application connections share fewer server connections. That changes the rules again — prepared statements and session-level state stop working the way you expect, and asyncpg needs statement_cache_size=0.

Verification

Assert that connections come back:

async def test_connections_return_to_the_pool(app, client):
    pool = app.state.engine.pool
    baseline = pool.checkedout()
    await asyncio.gather(*[client.get("/orders/1") for _ in range(20)])
    assert pool.checkedout() == baseline, "a request did not return its connection"

Run it against the endpoints that do anything unusual with sessions — background tasks, streaming responses, manually constructed sessions. Those are where leaks live; a plain handler using SessionDep is very hard to get wrong.

Assert the error path too, by configuring a deliberately tiny pool in one test and confirming the timeout is raised rather than hung. A test that hangs forever when the pool is empty is telling you your pool_timeout is not set.

In production, graph three series: checkedout(), its idle floor, and your rate of pool-timeout errors. The floor catches leaks early, the peak against capacity tells you when to resize, and the error rate tells you when the decision became urgent.

Trade-offs and When Not To

Raising pool_size is the wrong first move. It converts an application bug into database load, and if the cause is a leak it buys you hours rather than fixing anything. Check the idle floor first.

More connections is not more throughput. Past a point, a database serves a smaller number of connections faster than a larger number, because of context switching and lock contention. Pools sized well above your actual concurrency make things worse.

pool_pre_ping costs a round trip per checkout. On a very hot path with a stable network, that overhead is real. Turn it off only if you have confirmed nothing between you and the database drops idle connections.

Short transactions weaken atomicity. Moving work outside the transaction means the row can change under you. That is usually the right trade, but it is a trade — and it needs a version check, not a hope.

FAQ

What does the QueuePool timeout error actually say? It names the exact limits it hit, for example QueuePool limit of size 2 overflow 0 reached, connection timed out, timeout 2.50. Those three numbers are your pool_size, your max_overflow and your pool_timeout, which tells you immediately whether to change sizing or find a leak.

How do I tell a leak from an undersized pool? Watch pool.checkedout() when the service is idle. A correctly scoped app returns to zero between requests. In a measured run, three yield-scoped requests left checkedout at 0 while requests through a returning dependency drained the pool and never gave anything back.

Does pool exhaustion always produce errors? No, and that is why it is missed. Requests wait for a free connection first and only fail once they exceed pool_timeout. In a measured run, four concurrent requests through a two-slot pool all returned 200; the same pool with ten concurrent requests returned six successes and four timeouts.

Why does holding a transaction over an external API call exhaust the pool? The connection stays checked out for the whole transaction. Measured directly, an upstream call inside the transaction held its connection for 0.15s, while moving the same call outside dropped the hold to effectively zero with no change to overall request latency.

How do I size pool_size and max_overflow? One worker can open pool_size plus max_overflow connections. Multiply by worker count, add anything your background workers and migration tooling need, and keep the total safely under the database's max_connections. Size from measured concurrency rather than guessing high.

Is asyncpg's own pool the same as SQLAlchemy's? No. If you use SQLAlchemy's async engine, SQLAlchemy's pool is in charge and asyncpg's own pooling is not used. The parameters that matter are then pool_size, max_overflow and pool_timeout on create_async_engine, not asyncpg's min_size and max_size.