A pool leak is an ownership failure: application work finishes, but its borrowed connection remains checked out. The database may still answer other clients normally while this application has no leases left. Treat release as part of the operation’s contract, including exceptions and early returns, rather than a cleanup courtesy after success.
A lease has an owner and an end
A SQLAlchemy Connection represents a borrow from its Engine’s pool. Closing that Connection normally returns the underlying DBAPI connection; it does not necessarily disconnect it. Returning the lease and committing a transaction are separate decisions. A pool can therefore be healthy while business changes were rolled back, or exhausted while its physical database connections remain alive.
Use the smallest ownership scope consistent with the transaction’s atomicity. with engine.begin() commits a successful block, rolls back a failed block, and releases its connection. with engine.connect() scopes the connection, but does not promise to commit writes: an outstanding transaction is rolled back when the connection closes. Do not replace one with the other without checking the intended persistence behavior.
Keep unrelated network waits and user interaction outside a database transaction. That does not justify splitting changes that must commit together. If a caller owns the connection, a helper should not secretly close it; if the helper borrows it, its interface must make any transfer of ownership explicit. Counting opens without identifying the responsible owner misses the real boundary.
When it breaks
A forgotten release reduces usable capacity until another checkout waits or times out. With a finite QueuePool and acquisition timeout, new callers need not hang forever: they receive a pool timeout before executing SQL. Raising the limit can delay recurrence while increasing pressure on the database; it does not repair the missing release.
Exhaustion alone does not prove a leak. Legitimate concurrency, a long transaction, a database lock, or an application deadlock can hold every lease. Flat database CPU is not decisive, because an application can hold a connection while waiting elsewhere. Correlate checked-out counts, checkout wait, lease age, transaction state, and the work that actually remains active.
A useful leak reproduction shows that completed work still owns leases and that returning those exact leases restores capacity. Do not depend on garbage collection to establish timely release. Likewise, Engine.dispose() handles idle connections in its pool, not forced reclamation of connections still checked out. Restarting a process may hide the ownership bug without correcting its next occurrence.
Check it quickly
In an empty macOS or Linux directory, run python3 -m venv .venv and .venv/bin/python -m pip install SQLAlchemy==2.0.52. Save this as pool_probe.py, then run .venv/bin/python pool_probe.py. The checked environment was Python 3.14.4 with SQLite 3.53.0. The temporary database is local and automatically removed.
from pathlib import Path
from tempfile import TemporaryDirectory
import sqlalchemy
from sqlalchemy import create_engine
from sqlalchemy.exc import TimeoutError as PoolTimeout
from sqlalchemy.pool import QueuePool
class WorkFailed(Exception):
pass
assert sqlalchemy.__version__ == "2.0.52"
with TemporaryDirectory(prefix="pool-leak-") as directory:
engine = create_engine(
"sqlite:///" + str(Path(directory) / "probe.db"),
poolclass=QueuePool, pool_size=2, max_overflow=0, pool_timeout=0.05,
)
borrowed = []
try:
with engine.begin() as connection:
connection.exec_driver_sql("CREATE TABLE ledger(id INTEGER PRIMARY KEY)")
for _ in range(2):
borrowed.append(engine.connect()) # Deliberately retain both leases.
assert engine.pool.checkedout() == 2
try:
unexpected = engine.connect()
except PoolTimeout:
pass
else:
unexpected.close()
raise AssertionError("retained leases must exhaust the two-slot pool")
assert engine.pool.checkedout() == 2
print("two retained leases exhaust the pool; the next checkout times out")
borrowed[0].close()
with engine.begin() as connection:
connection.exec_driver_sql("INSERT INTO ledger VALUES (1)")
assert engine.pool.checkedout() == 1
print("returning one lease restores capacity without enlarging the pool")
try:
with engine.begin() as connection:
connection.exec_driver_sql("INSERT INTO ledger VALUES (2)")
raise WorkFailed("injected after the insert")
except WorkFailed:
pass
assert engine.pool.checkedout() == 1
with engine.connect() as connection:
assert connection.exec_driver_sql("SELECT id FROM ledger").scalars().all() == [1]
connection.exec_driver_sql("INSERT INTO ledger VALUES (3)")
# No commit: this connection context rolls back when it closes.
with engine.connect() as connection:
assert connection.exec_driver_sql("SELECT id FROM ledger").scalars().all() == [1]
print("an exception rolls back row 2 and returns its lease; closing without commit also removes row 3")
def early_return():
with engine.connect() as connection:
return connection.exec_driver_sql("SELECT 1").scalar_one()
assert early_return() == 1 and engine.pool.checkedout() == 1
with engine.begin() as connection:
connection.exec_driver_sql("INSERT INTO ledger VALUES (4)")
with engine.connect() as connection:
rows = connection.exec_driver_sql("SELECT id FROM ledger ORDER BY id").scalars().all()
assert rows == [1, 4] and engine.pool.checkedout() == 1
borrowed[1].close()
assert engine.pool.checkedout() == 0 and engine.pool.checkedin() == 2
print("early return releases its lease; successful transaction commits row 4; final rows", rows)
finally:
for connection in borrowed:
connection.close()
engine.dispose()
The list deliberately holds strong references to both borrowed connections, making the simulated leak independent of garbage collection. A third checkout raises PoolTimeout. Returning one retained lease lets a transaction insert row 1 without enlarging the pool. One deliberate leak remains, so the expected checked-out count between properly scoped operations is one, not zero.
The injected WorkFailed exception occurs after inserting row 2. Exiting the transaction context rolls that insert back and returns the lease. A separate connection context inserts row 3 without committing; closing it also removes that row. An early return still exits its connection context. Finally, a successful transaction commits row 4, and the ordered result must be exactly [1, 4].
Closing the last intentionally retained lease brings checked-out count to zero while two physical connections remain idle for reuse. The final cleanup returns all retained handles before disposing the Engine. These assertions test local pool accounting and transaction outcomes, not production lock contention, cancellation, or distributed side effects. Returning a connection does not undo an HTTP call already sent elsewhere. Repeat the ownership test around the actual failure path before changing pool capacity.
Sources
- SQLAlchemy connection pooling, checkouts, return and finite QueuePool limits, checked 2026-09-09.
- SQLAlchemy Engine and Connection, transaction contexts, close and disposal semantics, checked 2026-09-09.
- SQLAlchemy pool exhaustion errors, leaks, long transactions, concurrency and deadlocks, checked 2026-09-09.