A connection cap limits one owner’s demand; it does not automatically cap every application sharing a database. The useful budget is the sum of physical connections that can reach each backend, including idle pooled connections. Begin with the backend’s usable slots, then allocate them across all callers and deployment states.
Count pools before choosing their size
For a finite SQLAlchemy QueuePool, simultaneous connections are bounded by pool_size + max_overflow. That is a per-pool limit. Engines with separately created QueuePools have independent limits; worker processes and replicas can multiply them again. Count actual pools, since derived Engine views can share one. For identical owners, multiply replicas, processes per replica, pools per process, and the per-pool cap. For different owners, sum their individual caps instead.
Count the maximum overlapping deployment, not merely the number of ready replicas after rollout. New replicas can open connections while old replicas still drain work or retain idle connections. Include background jobs, migration tools, monitoring, and other services in the inventory. Connections to different database servers require separate budgets rather than one number that hides the constrained target.
PostgreSQL’s max_connections is the server ceiling, not the application’s allocation. Ordinary roles without reserved-slot privileges must leave both superuser_reserved_connections and reserved_connections untouched. The latter is for roles with pg_use_reserved_connections privileges. Explicitly subtract other ordinary clients and operating margin as well; do not borrow emergency capacity for routine traffic.
When it breaks
A service can respect every local pool cap and still exhaust the database because there are more pools than the worksheet counted. Unlimited overflow defeats that worksheet altogether. Conversely, an excessively small cap can cause acquisition waits even when the backend has capacity. Neither observation establishes that changing the database ceiling is the right fix.
More connections do not create more CPU, faster storage, or fewer conflicting transactions. PostgreSQL allocates some resources according to its configured ceiling, so raising it also has resource consequences. Examine acquisition waits, physical connections, active work, and transaction duration together. A cap constrains connection count; it does not guarantee throughput, fairness between services, or short lock waits.
An external pooler adds another accounting boundary. PgBouncer session pooling retains a server connection for the client session, whereas transaction pooling returns it after each transaction. Client connections therefore are not interchangeable with backend-session counts. Inventory the pooler’s server-side allocation and pooling mode; transaction mode changes assumptions about session features, so adopting it is not just a capacity-number edit.
Check it quickly
Use an empty directory on macOS or Linux. Run python3 -m venv .venv, then .venv/bin/python -m pip install SQLAlchemy==2.0.52. Save this as pool_probe.py and run .venv/bin/python pool_probe.py. It was checked with Python 3.14.4 and SQLite 3.53.0. Its three real pools live in one process and share a temporary local database file.
from pathlib import Path
from tempfile import TemporaryDirectory
import sqlalchemy
from sqlalchemy import create_engine, event
from sqlalchemy.exc import TimeoutError as PoolTimeout
from sqlalchemy.pool import QueuePool
assert sqlalchemy.__version__ == "2.0.52"
with TemporaryDirectory(prefix="pool-caps-") as directory:
engines, held, opened, closed = [], [], [0, 0, 0], [0, 0, 0]
try:
for owner in range(3):
engine = create_engine(
"sqlite:///" + str(Path(directory) / "probe.db"),
poolclass=QueuePool, pool_size=2, max_overflow=1, pool_timeout=0.05,
)
engines.append(engine)
def on_connect(dbapi_connection, entry, index=owner):
opened[index] += 1
def on_close(dbapi_connection, entry, index=owner):
closed[index] += 1
event.listen(engine, "connect", on_connect)
event.listen(engine, "close", on_close)
for _ in range(3):
held.append(engine.connect())
try:
unexpected = engine.connect()
except PoolTimeout:
pass
else:
unexpected.close()
raise AssertionError("each pool must reject a fourth simultaneous lease")
assert len({id(engine.pool) for engine in engines}) == 3
view = engines[0].execution_options(logging_token="same-pool")
assert view.pool is engines[0].pool
print("an Engine execution-options view shares its parent pool; it adds no connection slots")
assert opened == [3, 3, 3]
assert [engine.pool.checkedout() for engine in engines] == [3, 3, 3]
print("three independent pools each cap at 3, yet together hold 9 physical SQLite connections")
for connection in held:
connection.close()
assert [engine.pool.checkedin() for engine in engines] == [2, 2, 2]
assert closed == [1, 1, 1]
print("after return, 6 connections remain idle; the 3 overflow connections are closed")
finally:
for connection in held:
connection.close()
for engine in engines:
engine.dispose()
assert opened == closed == [3, 3, 3]
def demand(replicas, processes, pools_per_process, size, overflow):
return replicas * processes * pools_per_process * (size + overflow)
# Illustrative PostgreSQL worksheet, not a live server configuration or benchmark.
maximum, superuser_reserve, role_reserve = 100, 3, 5
other_clients, operating_margin = 24, 8
budget = maximum - superuser_reserve - role_reserve - other_clients - operating_margin
assert budget == 60
assert demand(6, 1, 1, 8, 2) == 60
assert demand(8, 1, 1, 8, 2) == 80 > budget # Two extra replicas during rollout.
assert demand(8, 1, 1, 6, 1) == 56 <= budget
assert demand(8, 2, 1, 6, 1) == 112 > budget # A second process creates another pool.
assert demand(8, 1, 2, 6, 1) == 112 > budget # A second independent pool per process.
print("ordinary-client allocation: 60; steady demand 60; rollout demand 80; revised rollout demand 56")
print("doubling either processes or pools raises that revised demand to 112; no PostgreSQL server was run")
Each pool holds three physical SQLite connections and rejects a fourth checkout with PoolTimeout. Together they hold nine, proving that a local cap of three is not a database-wide cap of three. An execution-options Engine view shares the first pool and adds no capacity. Returning the leases retains six idle connections and closes three excess connections. Disposing all Engines after return makes the observed connect and close event counts agree.
The second half is explicitly an arithmetic model, not a running PostgreSQL configuration. Its illustrative ceiling of 100 loses three superuser slots, five reserved-role slots, 24 other ordinary-client slots, and eight margin slots, leaving 60. Five reserved-role slots are a chosen example; PostgreSQL’s documented default for that setting is zero.
Six replicas with caps of ten fit exactly, but eight overlapping replicas require 80. Revising each cap to six retained plus one overflow gives 56 during overlap. Adding a second worker process or a second pool per process doubles that to 112, exceeding the same allocation.
These checks establish local pool behavior and budget arithmetic, not production performance or a safe universal pool size. No PostgreSQL server, PgBouncer instance, or real rollout was exercised. Before changing production limits, verify the actual pool inventory and maximum overlap, then measure waiting and backend saturation under the intended workload.
Sources
- SQLAlchemy connection pooling, finite QueuePool capacity and idle retention, checked 2026-09-09.
- SQLAlchemy Engine and Connection, derived Engine views can share a connection pool, checked 2026-09-09.
- SQLAlchemy pool events, observed connection creation and close events, checked 2026-09-09.
- PostgreSQL connection settings, server ceiling and reserved-slot privileges, checked 2026-09-09.
- PgBouncer features, session and transaction pooling boundaries, checked 2026-09-09.