Fixing "garbage collector is trying to clean up non-checked-in connection" warnings

The warning means a connection was checked out and never returned — a session, AsyncConnection or streamed result that was not closed — so wrap every one in async with, and add checkout tracking to find the call site that is not. This guide belongs to handling connection leaks and pool exhaustion.

Quick Answer

The full message under asyncpg reads: The garbage collector is trying to clean up non-checked-in connection <AdaptedConnection <asyncpg.connection.Connection object at 0x7f…>>, which will be dropped, as it cannot be safely terminated. Please ensure that SQLAlchemy pooled connections are returned to the pool explicitly, either by calling close() or by using appropriate context managers to manage their lifecycle.

How a connection reaches the garbage collector Five steps. A session or AsyncConnection checks a connection out of the pool. The code path that should close it does not: no async with, an early return, or a cancelled task. The last Python reference to the session is dropped. The garbage collector finalises the pool proxy and notices the connection was never returned. Under asyncio it cannot run the asynchronous close, so it drops the connection and emits the warning. checkout session.execute() takes a connection the pool counts it as out no close no async with / early return / cancel nothing returns it last reference dropped session goes out of scope connection still counted as out garbage collection pool finalizer runs cannot await close() here connection dropped warning logged The warning is the pool reporting a leak after the fact. The bug is wherever step two happened.

It is not a driver problem. Something checked a connection out and nothing gave it back.

Before — a helper that opens a session and never closes it:

from sqlalchemy import select

from shop.db import Session
from shop.models import Customer


async def find_customer(email: str) -> Customer | None:
    session = Session()                     # checked out on first execute
    customer = await session.scalar(select(Customer).where(Customer.email == email))
    if customer is None:
        return None                         # early return: session never closed
    await session.close()
    return customer

After — the session's lifetime is a block:

from sqlalchemy import select

from shop.db import Session
from shop.models import Customer


async def find_customer(email: str) -> Customer | None:
    async with Session() as session:
        return await session.scalar(select(Customer).where(Customer.email == email))

The async with form closes the session on every path — normal return, early return, exception and task cancellation — which is why it is the only form worth using. The same applies to engine.connect(), engine.begin() and session.stream().

Execution Context & Async Workflow Integration

SQLAlchemy's pool hands out connections wrapped in a small proxy object and counts them as checked out until the proxy is returned. Returning is explicit: closing a session releases its connection, closing an AsyncConnection releases it, and exiting their context managers closes them. If none of that happens, the proxy stays alive as long as something references the session — and when the last reference goes, Python's garbage collector eventually finalises it.

Four places connections leak Four tiles. Sessions created without async with, such as session = Session() in a helper, which are never closed. AsyncConnection objects from engine.connect() without a context manager. Streaming results from session.stream that are abandoned before being fully consumed or closed. Tasks cancelled between checkout and close, where the cleanup code was not in a finally block or context manager. session = Session() never closed helpers and repositories conn = await engine.connect() no async with scripts and startup code await session.stream(...) abandoned mid-iteration break, return, exception cancelled tasks cleanup not in finally timeouts, disconnects Every one of these is fixed by the same construct: an async with block around the resource.

At that point the pool notices a connection that was never checked in. With a synchronous driver it can close the connection right there. With asyncpg it cannot: closing an asyncpg connection is a coroutine, and a finaliser running inside the garbage collector cannot await anything or be sure an event loop is even running. So SQLAlchemy drops the connection object, logs the warning, and adjusts the pool's count. PostgreSQL sees the socket disappear eventually and ends the backend.

Three consequences are worth understanding. First, the warning arrives late and at an arbitrary place — during garbage collection, possibly in a different request, possibly minutes after the leak — so its stack trace says nothing about the cause. Second, until the collector runs, the leaked connection counts against pool_size + max_overflow, so leaks show up first as QueuePool limit reached timeouts and only later, if at all, as this warning. Third, any transaction the connection had open is neither committed nor cleanly rolled back from the application's side; the server rolls it back when the connection finally dies, holding its locks until then.

Two situations produce the warning without a real leak in application code. At interpreter shutdown, objects are collected in an arbitrary order after the event loop has closed, so a service that exits without await engine.dispose() can log it for connections that were merely idle in sessions still referenced from module globals. And a test suite that creates engines per test without disposing them produces a stream of these warnings as fixtures go out of scope. Both are fixed by disposing engines deliberately, shown below.

Resolving Warnings, Errors & Common Mistakes

Warning, error or symptomRoot CauseProduction Fix
The garbage collector is trying to clean up non-checked-in connectionA session, connection or stream was not closed.async with around every session, connection and stream.
The warning appears only at process exitEngine not disposed; sessions referenced from globals.await engine.dispose() in lifespan shutdown.
RuntimeError: Event loop is closed in "Exception ignored in" outputCleanup attempted after asyncio.run() finished.Dispose engines inside the coroutine, before the loop closes.
QueuePool limit of size 10 overflow 5 reached hours after deployA slow leak exhausting the pool before collection reclaims connections.Checkout tracking to find the call site.
idle in transaction backends accumulating in pg_stat_activityLeaked sessions with open transactions holding their connections.Same fix; set idle_in_transaction_session_timeout as a backstop.
Warnings flood the test runEngines created per test and never disposed.Session-scoped engine fixture with await engine.dispose() at teardown.
Make the warning say where Left: the default warning names the asyncpg connection object and nothing else, so the code path that leaked it is unknown. Right: a checkout event stores a short stack trace on the connection record; a periodic report lists connections that have been out longer than a threshold, with the stack that checked them out, pointing at the exact function. the warning alone non-checked-in connection <AdaptedConnection <asyncpg ...>> no file, no function, no request which of 400 call sites? with checkout tracking checked out 94 s ago shop/reports.py:212 build_summary shop/api/orders.py:88 list_orders the leak, by name Record stacks only in staging or behind a flag: capturing a stack on every checkout is not free.

Streams deserve a specific note because the leak is not obvious. session.stream() returns an AsyncResult backed by a server-side cursor on a checked-out connection. Iterating it to the end releases the cursor, but breaking out of the loop early leaves it open until the result is closed:

from sqlalchemy import select

from shop.db import Session
from shop.models import Order


async def first_large_order(threshold_cents: int) -> Order | None:
    async with Session() as session:
        async with (await session.stream_scalars(
            select(Order).order_by(Order.id).execution_options(yield_per=500)
        )) as orders:
            async for order in orders:
                if order.total_cents >= threshold_cents:
                    return order      # the inner async with closes the cursor
    return None

Cancellation is the last common source. A task cancelled between checkout and close only cleans up if the close is in a context manager or a finally block. Code that calls await session.close() as its last line leaks on every timeout. Streaming patterns in general are covered in using yield_per to stream millions of rows.

Advanced: Finding the Leak With Checkout Tracking

When a codebase has hundreds of places that open sessions, reading them all is slower than asking the pool which one leaked. Pool events make that straightforward: record a short stack trace when a connection is checked out, discard it when it is checked in, and periodically report anything that has been out for too long.

A slow leak is a delayed outage Bar chart. Healthy service: checked-out connections stay around 4. Leaking service after 10 minutes: around 7. After 30 minutes: around 11. After 60 minutes: all 15 connections are out and requests start timing out, unless garbage collection happens to reclaim some first. healthy, any time ≈ 4 checked out leaking, 10 minutes ≈ 7 leaking, 30 minutes ≈ 11 leaking, 60 minutes 15 of 15 — QueuePool limit reached Illustrative. Garbage collection can hide the leak for hours by reclaiming connections at random.
from __future__ import annotations

import asyncio
import logging
import time
import traceback

from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine

log = logging.getLogger("db.leaks")


def install_checkout_tracking(engine: AsyncEngine, *, max_age_s: float = 60.0,
                              every_s: float = 30.0) -> asyncio.Task:
    pool = engine.sync_engine.pool
    outstanding: dict[int, tuple[float, str]] = {}

    @event.listens_for(pool, "checkout")
    def _checkout(dbapi_conn, record, proxy):
        stack = "".join(traceback.format_stack(limit=12)[:-2])
        outstanding[id(record)] = (time.monotonic(), stack)

    @event.listens_for(pool, "checkin")
    def _checkin(dbapi_conn, record):
        outstanding.pop(id(record), None)

    @event.listens_for(pool, "invalidate")
    def _invalidate(dbapi_conn, record, exception):
        outstanding.pop(id(record), None)

    async def report() -> None:
        while True:
            await asyncio.sleep(every_s)
            now = time.monotonic()
            for started, stack in list(outstanding.values()):
                if now - started > max_age_s:
                    log.warning(
                        "connection checked out for %.0fs", now - started,
                        extra={"checkout_stack": stack},
                    )

    return asyncio.create_task(report(), name="db-checkout-report")

Run it in staging, or in production behind a flag for a short window: traceback.format_stack() on every checkout costs tens of microseconds, which is noticeable on a hot path. The report names the function that checked out the connection, and almost always that function, or its caller, is missing an async with.

The age threshold should sit above your longest legitimate checkout. A report streaming for five minutes is not a leak; a request handler holding a connection for sixty seconds almost certainly is. Pairing this with the metrics from instrumenting and observing async queries — checked-out connections over time — shows whether a fix actually stopped the climb.

Finally, shut down cleanly so the warning stops being noise at exit. In FastAPI, dispose the engine in the lifespan; in scripts, dispose inside the coroutine passed to asyncio.run():

import asyncio

from shop.db import engine
from shop.jobs import rebuild_search_index


async def main() -> None:
    try:
        await rebuild_search_index()
    finally:
        await engine.dispose()   # closes pooled connections while the loop is alive


asyncio.run(main())

Keeping Leaks From Coming Back

A leak fixed once tends to return with the next helper someone writes in a hurry. Two cheap guards catch the pattern before it ships: a test-suite assertion that nothing is left checked out, and a review rule for how sessions may be created.

Two guards against regressions Two bands. The autouse fixture records pool.checkedout before each test, forces garbage collection after it, and fails the test if the count has grown, so the leaking test is the failing test. The CI rule parses the source and fails when a session factory is called anywhere other than as the expression of an async with statement. after every test: pool.checkedout() is back to where it was gc.collect() first, so the test that leaked is the test that fails in CI: Session() only ever appears inside async with a small AST check with an allow-list for the few legitimate exceptions

The assertion belongs in an autouse fixture. After each test, every session the test opened should be closed, so the pool's checked-out count should be back where it started:

import gc

import pytest
import pytest_asyncio

from shop.db import engine


@pytest_asyncio.fixture(autouse=True)
async def no_leaked_connections():
    pool = engine.sync_engine.pool
    before = pool.checkedout()
    yield
    gc.collect()   # make leaked proxies visible now rather than in a later test
    leaked = pool.checkedout() - before
    assert leaked <= 0, f"{leaked} connection(s) still checked out after the test"

Calling gc.collect() before the check is deliberate. Without it, a leaked session that happens to survive until a later test is blamed on that test, which sends people looking in the wrong place. With it, the failing test is the one that leaked.

The review rule is simpler to state than to enforce: a session factory call is always the expression of an async with. Session() on its own line, assigned to a variable, is a leak waiting for an early return. A few lines of an AST check in CI turn the rule into a failure:

import ast
import pathlib
import sys

FACTORIES = {"Session", "AsyncSession", "BackgroundSession"}


def bare_session_calls(path: pathlib.Path) -> list[int]:
    tree = ast.parse(path.read_text(), filename=str(path))
    managed = {
        id(item.context_expr)
        for node in ast.walk(tree) if isinstance(node, ast.AsyncWith)
        for item in node.items
    }
    return [
        node.lineno
        for node in ast.walk(tree)
        if isinstance(node, ast.Call)
        and isinstance(node.func, ast.Name)
        and node.func.id in FACTORIES
        and id(node) not in managed
    ]


problems = [
    f"{path}:{line}" for path in pathlib.Path("shop").rglob("*.py")
    for line in bare_session_calls(path)
]
if problems:
    print("session created outside async with:
  " + "
  ".join(problems))
    sys.exit(1)

The check has false positives — a dependency that yields from async with Session() is fine and passes, but a factory passed as a callback may not — so give it an allow-list rather than weakening the rule.

Frequently Asked Questions

Is the garbage collector warning harmful or just noise?

It always indicates a connection that was not returned explicitly. At process exit that is usually harmless; during normal operation it means a leak that will eventually exhaust the pool or leave transactions holding locks.

Why can SQLAlchemy not just close the connection itself?

Closing an asyncpg connection requires awaiting a coroutine, and garbage collection runs synchronous finalisers at unpredictable moments, possibly without a running event loop. Dropping the connection is the only safe option.

Does pool_pre_ping fix leaked connections?

No. Pre-ping tests connections as they are checked out, to catch ones the server closed. A leaked connection is never checked in, so pre-ping never sees it.

Can I turn the warning into an exception in tests?

Not reliably, because it is raised inside a finaliser where exceptions are ignored. Instead, assert in a teardown fixture that engine.sync_engine.pool.checkedout() is zero after each test.