Diagnose SQLAlchemy 2.0 async connection-pool problems with one line of code.
sqla-pool-doctor attaches to any AsyncEngine (or sync Engine) and tells you
when connections are leaking, when the pool is exhausted, and where in your code
the offending checkout happened — with full stack traces.
from sqlalchemy.ext.asyncio import create_async_engine
from sqla_pool_doctor import attach
engine = create_async_engine("postgresql+asyncpg://...")
monitor = attach(engine, leak_threshold_seconds=10)
await monitor.start() # emit pool stats every 5s and warn on leaksThat's it. The next time a request handler forgets to release a connection, you'll see:
WARNING sqla_pool_doctor: potential connection leak: held 12.3s (threshold 10.0s)
File "app/handlers.py", line 47, in get_user
conn = await engine.connect()
File "app/handlers.py", line 51, in get_user
return await _fetch(conn, user_id)
Not yet on PyPI — install directly from GitHub:
pip install git+https://github.com/async-workflows/sqla-pool-doctor.gitPin to a specific commit or tag for reproducibility:
pip install "git+https://github.com/async-workflows/sqla-pool-doctor.git@main"The package ships with a self-contained demo that intentionally leaks a connection, saturates the pool to capacity, and shows the resulting warnings so you can see the output before integrating it:
pip install "sqla-pool-doctor[demo] @ git+https://github.com/async-workflows/sqla-pool-doctor.git"
sqla-pool-doctor demoFor a live-updating table of the pool snapshot, install the [panel] extra
(which pulls in rich) and add --panel:
pip install "sqla-pool-doctor[demo,panel] @ git+https://github.com/async-workflows/sqla-pool-doctor.git"
sqla-pool-doctor demo --panelIf rich is not installed, --panel degrades gracefully to the plain text
output with a hint, so the demo always runs.
from sqla_pool_doctor import attach
monitor = attach(engine)
await monitor.start()Pass a custom logger or thresholds:
import logging
monitor = attach(
engine,
leak_threshold_seconds=5.0,
poll_interval_seconds=2.0,
logger=logging.getLogger("myapp.db"),
)If you don't want a background task, call report() whenever you want a stats
line and a leak scan logged on demand:
monitor = attach(engine)
monitor.report() # logs one pool-stats line + any leak warningssnapshot() returns a JSON-serializable PoolSnapshot dataclass — perfect for
a /healthz endpoint. It never logs; it just hands you the numbers:
monitor = attach(engine)
@app.get("/healthz/pool")
def pool_status():
return monitor.snapshot().as_dict()The snapshot includes the live pool counters, the peak concurrent checkouts observed, running hold-time statistics, and the currently-outstanding checkouts with their age:
{
"pool_size": 5,
"checked_out": 2,
"overflow": 0,
"checked_in": 3,
"capacity": 15,
"outstanding_tracked": 2,
"peak_checked_out": 6,
"exhausted": false,
"hold_time": {"count": 1240, "min": 0.001, "max": 4.12, "mean": 0.031, "p95": 0.12},
"outstanding": [
{"age_seconds": 3.4, "stack": " File \"app/handlers.py\", line 47, ..."}
]
}capacity is size + max_overflow when the pool reports it (e.g. QueuePool),
otherwise null. Hold-time stats are in seconds and are updated on every
checkin.
When checked_out reaches the pool's capacity, the monitor logs a single
warning per exhaustion episode (it de-dupes and resets once the pool recovers),
so a saturated pool won't flood your logs:
WARNING sqla_pool_doctor: pool exhausted: checked_out=15 reached capacity=15 (size + max_overflow); further checkouts will block
A checkout held past leak_threshold_seconds is reported as a potential leak.
The warning fires immediately at checkin for a connection that was held too
long, as well as during the periodic poll loop for one that is still held — with
no double-warning for the same checkout.
Pass on_leak to receive a callback (in addition to the log warning) whenever a
leak is detected — wire it to your metrics, Sentry, or a pager:
def alert(info: dict) -> None:
metrics.increment("db.pool.leak")
log.error("connection held %.1fs", info["held_seconds"])
monitor = attach(engine, leak_threshold_seconds=10, on_leak=alert)The callback receives a dict with held_seconds, threshold_seconds,
stack, and conn_id. Callback exceptions are swallowed and logged, so a
faulty callback can never break monitoring.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqla_pool_doctor import attach
@asynccontextmanager
async def lifespan(app: FastAPI):
monitor = attach(app.state.engine, leak_threshold_seconds=10)
await monitor.start()
try:
yield
finally:
await monitor.stop()
app = FastAPI(lifespan=lifespan)| Signal | When you'll see it |
|---|---|
pool stats |
Every poll_interval_seconds once start() has been called. |
connection leak |
A checkout held longer than leak_threshold_seconds (at checkin too). |
pool exhausted |
checked_out reaches capacity — once per episode, de-duped. |
| Hold-time stats | Recorded on every checkin; read them via snapshot(). |
| Stack trace | Captured at checkout time, so you can see exactly where it began. |
Stats come from the engine's own pool: size(), checkedout(), overflow(),
checkedin() — whichever the pool implementation exposes.
- SQLAlchemy 2.0+
- Any async driver:
asyncpg,aiosqlite,aiomysql,asyncmy,aioodbc - Any pool class that fires
checkout/checkinevents (the defaultQueuePool/AsyncAdaptedQueuePool,StaticPool,SingletonThreadPool) - Sync
Enginetoo — justattach(engine)works
Connection leaks and pool exhaustion are some of the hardest-to-debug production problems in async Python services. The pool runs out, requests pile up, and the stack trace points at code that's just waiting for a connection — not at the handler that forgot to release one.
sqla-pool-doctor records the stack at checkout, so when a leak warning
fires, you see the real origin.
For deeper coverage of the underlying mechanics — pool sizing, overflow behaviour, leak patterns, and how to recover gracefully — see the companion guides on async engines and connection pooling:
- Configuring async engines and connection pools
- Handling connection leaks and pool exhaustion
- Configuring pool pre-ping to handle stale connections
- Setting pool size and max overflow for AWS RDS
- Choosing between asyncpg and psycopg async drivers
MIT