Database Connection Pooling Explained
Opening a database connection is expensive enough that doing it once per request will cap your throughput long before the database itself becomes the bottleneck. Pooling is how you stop paying that cost on every request.
Published July 6, 2026Establishing a new database connection involves a TCP handshake, often a TLS handshake on top of it, authentication, and the database allocating memory and process or thread resources for the session. Depending on the database and network, that can take anywhere from a few milliseconds to tens of milliseconds — small on its own, but ruinous if you pay it on every single request when your actual query takes 2 milliseconds to run. A connection pool keeps a set of already-established connections open and hands one out to whichever part of your application needs to run a query, then takes it back when the query finishes, instead of tearing the connection down.
The basic lifecycle
A pool starts with some minimum number of connections already open (or opens them lazily on first use, depending on configuration). When your code needs to run a query, it checks out a connection from the pool, runs the query, and returns the connection — not closes it, returns it to the pool for the next caller to reuse. If every connection is currently checked out when a new request needs one, the requester waits (up to a configurable timeout) until one frees up, or the pool opens a new connection if it's below its configured maximum.
pool = ConnectionPool(
min_size=5,
max_size=20,
max_idle_time=300, # seconds before an idle connection is closed
connect_timeout=5,
)
with pool.acquire() as conn:
conn.execute("SELECT * FROM orders WHERE id = %s", [order_id])
Why the database can't just handle unlimited connections
Every open connection on the database side consumes memory, and on some databases (PostgreSQL notably) each connection is backed by its own OS process, not a lightweight thread. PostgreSQL's default max_connections setting is 100, and pushing it much higher without also giving the server more memory causes the server itself to slow down under connection overhead before it ever gets to executing your queries. Official PostgreSQL documentation on this setting is a good place to see the actual trade-offs the database authors describe: postgresql.org/docs/current/runtime-config-connection.html.
This is the core reason pooling exists at all: an application server running 50 worker processes, each opening its own database connection, could easily need 500+ simultaneous connections at peak — five times what the database is comfortable with. A pool caps how many connections your application actually opens, regardless of how many application workers or threads are trying to run queries at once.
Sizing a pool
The naive instinct is "bigger pool, more throughput," but a pool larger than the database can comfortably serve just moves the bottleneck: instead of your application waiting for a connection, the database is now serving more concurrent queries than its CPU and memory can handle efficiently, and every query gets slower. A commonly cited starting formula, from a widely referenced analysis by the makers of the HikariCP connection pool, is roughly connections = (core_count * 2) + effective_spindle_count for the database server — a small number, often well under 50, even for a machine handling thousands of requests per second, because most of that concurrency is handled by queueing at the pool, not by giving every request its own connection.
In practice, size the pool against the database's actual connection budget divided across however many application instances share that database, not against how many concurrent requests your web server can accept. If you run 10 application instances and the database allows 100 connections, each instance's pool should be sized well under 10, leaving headroom for migrations, admin connections, and other services sharing the same database.
Pool exhaustion
When every connection in the pool is checked out and a new request needs one, it waits. If queries are running slower than usual — a missing index, a lock wait, a slow query someone shipped without noticing — connections stay checked out longer, the pool empties faster than it refills, and new requests start timing out waiting for a connection that never frees up. This looks, from the outside, like the entire application went down, even though the database itself might be perfectly healthy; the actual problem is upstream, in the pool. Monitoring pool utilization (checked-out count versus max size, and average wait time to acquire a connection) catches this before it becomes an outage, because a pool that's consistently near its max size during normal traffic has no slack left for a spike.
Connection poolers as a separate layer
For very high connection counts, some teams add a dedicated pooler process (PgBouncer for PostgreSQL is the common example) between the application and the database, so the database itself only ever sees a small, stable number of connections, while the pooler juggles a much larger number of client-facing connections behind it. This is a different layer than your application's in-process pool, and the two are often used together: your application pools connections to the pooler, and the pooler pools connections to the actual database.