Database Connection Pooling: Patterns for High-Throughput Applications
How to configure and optimize database connection pools for production workloads, from basic sizing to advanced patterns like PgBouncer and ProxySQL.
February 19, 2026 · 10 min · 2117 words · Rob Washington
Table of Contents
Every database connection costs something: TCP handshake, TLS negotiation, authentication, session state allocation. For PostgreSQL, that’s 1.3-2MB of memory per connection. For MySQL, 256KB-1MB. At scale, creating connections on demand kills both your database and your latency.
Connection pooling solves this by reusing connections across requests. But misconfigured pools are worse than no pools — you get connection starvation, deadlocks, and debugging nightmares.
fromsqlalchemyimportcreate_enginefromsqlalchemy.poolimportQueuePoolengine=create_engine("postgresql://user:pass@localhost/db",poolclass=QueuePool,pool_size=10,# Steady-state connectionsmax_overflow=20,# Additional connections under loadpool_timeout=30,# Seconds to wait for connectionpool_recycle=1800,# Recycle connections after 30 minpool_pre_ping=True,# Verify connection before use)# Usagewithengine.connect()asconn:result=conn.execute("SELECT * FROM users")
const{Pool}=require('pg');constpool=newPool({host:'localhost',database:'mydb',user:'user',password:'pass',max:20,// Max connections
idleTimeoutMillis:30000,// Close idle connections after 30s
connectionTimeoutMillis:2000,// Fail fast if pool exhausted
});// Usage
constclient=awaitpool.connect();try{constres=awaitclient.query('SELECT * FROM users');returnres.rows;}finally{client.release();// Return to pool, don't close
}
The optimal pool size depends on your workload, not your traffic volume.
For CPU-bound queries:
For I/O-bound queries (typical web apps):
PostgreSQL’s recommendation:
A 4-core server with SSDs: (4 * 2) + 1 = 9 connections per application instance.
The counterintuitive truth: Larger pools are often slower. More connections mean more lock contention, more context switching, and more memory pressure. A pool of 50 connections fighting for locks is slower than a pool of 10 connections with a queue.
importtimeimportconcurrent.futuresfromsqlalchemyimportcreate_enginedefbenchmark_pool_size(pool_size,num_requests=1000):engine=create_engine("postgresql://user:pass@localhost/db",pool_size=pool_size,max_overflow=0,# Strict pool size)defmake_request():withengine.connect()asconn:conn.execute("SELECT pg_sleep(0.01)")# 10ms querystart=time.time()withconcurrent.futures.ThreadPoolExecutor(max_workers=100)asexecutor:futures=[executor.submit(make_request)for_inrange(num_requests)]concurrent.futures.wait(futures)duration=time.time()-startprint(f"Pool size {pool_size}: {duration:.2f}s ({num_requests/duration:.0f} req/s)")# Test different sizesforsizein[5,10,20,50,100]:benchmark_pool_size(size)
Application-level pools work within a single process. But modern deployments have many processes: multiple containers, serverless functions, autoscaling groups. Each maintains its own pool, multiplying connections to the database.
External poolers sit between your applications and the database, maintaining a shared pool.
# pgbouncer.ini[databases]mydb=host=postgres port=5432 dbname=mydb[pgbouncer]listen_addr=0.0.0.0listen_port=6432auth_type=md5auth_file=/etc/pgbouncer/userlist.txt# Pool settingspool_mode=transaction # Most commonmax_client_conn=1000 # Client connections to PgBouncerdefault_pool_size=20 # Connections per user/databasemin_pool_size=5reserve_pool_size=5reserve_pool_timeout=3# Connection lifecycleserver_lifetime=3600 # Recycle after 1 hourserver_idle_timeout=600 # Close idle server connections
Pool modes:
Mode
Description
Use Case
session
Connection held for entire client session
Legacy apps using session state
transaction
Connection released after each transaction
Most web applications
statement
Connection released after each statement
Simple queries, no transactions
The transaction mode gotcha: Prepared statements don’t work across transaction boundaries. Use DEALLOCATE ALL or disable prepared statements in your driver.
1
2
3
4
5
6
7
# SQLAlchemy with PgBouncer transaction modeengine=create_engine("postgresql://user:pass@pgbouncer:6432/db",connect_args={"prepare_threshold":None# Disable prepared statements})
importboto3importpsycopg2defhandler(event,context):# RDS Proxy handles connection poolingconn=psycopg2.connect(host="rds-proxy.proxy-xxx.us-east-1.rds.amazonaws.com",database="mydb",user="admin",password=get_secret()# Or use IAM auth)withconn.cursor()ascur:cur.execute("SELECT * FROM users")returncur.fetchall()
importpsycopg2# Connection created at module level persists across warm invocationsconn=Nonedefget_connection():globalconnifconnisNoneorconn.closed:conn=psycopg2.connect(host="postgres",database="mydb",user="user",password="pass")returnconndefhandler(event,context):connection=get_connection()withconnection.cursor()ascur:cur.execute("SELECT * FROM users")returncur.fetchall()
The limit: Lambda functions scale independently. 1000 concurrent invocations = 1000 connections, even with reuse. RDS Proxy caps this at the configured pool size.
Connections shouldn’t live forever. Memory leaks, statement cache growth, and server-side resource accumulation make old connections slower.
1
2
3
4
engine=create_engine("postgresql://...",pool_recycle=1800,# Recycle every 30 minutes)
For MySQL with wait_timeout:
1
2
3
4
5
6
# MySQL closes connections after wait_timeout (default 8 hours)# Recycle before that to avoid "MySQL server has gone away"engine=create_engine("mysql://...",pool_recycle=3600,# Recycle every hour)
fromsqlalchemyimportcreate_enginefromsqlalchemy.ormimportsessionmakerfromfunctoolsimportlru_cache@lru_cache(maxsize=100)defget_tenant_engine(tenant_id:str):"""Create a cached engine per tenant"""connection_string=get_tenant_connection_string(tenant_id)returncreate_engine(connection_string,pool_size=5,max_overflow=10,)defget_tenant_session(tenant_id:str):engine=get_tenant_engine(tenant_id)Session=sessionmaker(bind=engine)returnSession()# Usagewithget_tenant_session("tenant_123")assession:users=session.query(User).all()
Caveat: Unbounded tenant pools can exhaust database connections. Track total connections across all pools and implement eviction for inactive tenants.
-- PostgreSQL: active connections
SELECTdatname,count(*)asconnections,count(*)FILTER(WHEREstate='active')asactive,count(*)FILTER(WHEREstate='idle')asidle,count(*)FILTER(WHEREwait_event_type='Lock')aswaitingFROMpg_stat_activityWHEREdatname='mydb'GROUPBYdatname;
1
2
3
4
5
6
7
8
9
10
11
12
# SQLAlchemy pool statsfromsqlalchemyimportevent@event.listens_for(engine,"checkin")deflog_checkin(dbapi_conn,connection_record):pool=engine.poolprint(f"Pool: {pool.checkedout()}/{pool.size()} checked out")@event.listens_for(engine,"checkout")deflog_checkout(dbapi_conn,connection_record,connection_proxy):pool=engine.poolprint(f"Pool: {pool.checkedout()}/{pool.size()} checked out")
Enable connection validation (pool_pre_ping or equivalent)
Set connection recycling shorter than server timeout
Configure timeouts — fail fast is better than hang forever
Use external poolers for multi-process or serverless
Monitor pool metrics — exhaustion sneaks up on you
Test failover — what happens when the database restarts?
Connection pooling isn’t glamorous, but it’s the difference between an app that handles 100 requests/second and one that handles 10,000. Get it right once, and it quietly does its job forever. Get it wrong, and you’ll spend hours debugging mysterious timeouts at 2 AM.
The pool doesn’t care about your deadlines. Configure it properly.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.