Every database connection has a cost. TCP handshake, TLS negotiation, authentication, session setup—all before your first query runs. For PostgreSQL, that’s typically 20-50ms. For a single request, barely noticeable. For thousands of requests per second, catastrophic.
Connection pooling solves this by maintaining a set of pre-established connections that your application reuses. Done right, it’s one of the highest-impact performance optimizations you can make.
The Problem: Connection Overhead
Without pooling, every request cycle looks like this:
With pooling:
That’s a 5-10x improvement in latency, and it scales. Under load, connection establishment becomes a bottleneck—pooling eliminates it entirely.
Application-Level Pooling
Most ORMs and database libraries include built-in pooling. Configure it properly:
Python (SQLAlchemy)
| |
Node.js (pg)
| |
Go (database/sql)
| |
The Key Settings
Pool Size
Start with: connections = (CPU cores * 2) + effective_spindle_count
For most applications on modern SSDs, this simplifies to roughly 2x your CPU cores. A 4-core server might use 10-20 connections.
Too few connections: requests wait in queue, latency spikes. Too many connections: database overhead increases, memory usage grows, context switching hurts performance.
Connection Lifetime
Connections shouldn’t live forever:
| |
Why recycle?
- Memory leaks in connection state accumulate
- Network equipment (firewalls, load balancers) may drop idle connections
- Database server restarts leave stale connections
Health Checks
Stale connections cause errors. Verify before use:
| |
This runs a lightweight query (SELECT 1) before handing out a connection. Small overhead, but prevents “connection reset” errors that would otherwise reach your application.
External Poolers: PgBouncer
Application-level pooling works, but has limits:
- Each application instance maintains its own pool
- 10 instances × 20 connections = 200 database connections
- Scale to 50 instances and you’re at 1,000 connections
- PostgreSQL starts struggling around 500-1,000 connections
Enter PgBouncer: an external connection pooler that sits between your application and database.
Instead of each application maintaining connections to PostgreSQL, they connect to PgBouncer. PgBouncer maintains a smaller pool to the actual database.
PgBouncer Configuration
| |
Pool Modes
Session mode: Connection held for entire client session. Like no pooling at all—only useful for compatibility.
Transaction mode: Connection returned after each transaction. Most common choice. Works with most applications.
Statement mode: Connection returned after each statement. Most efficient, but breaks multi-statement transactions and prepared statements.
For most applications, transaction mode is the sweet spot.
Connection Pool Monitoring
You can’t optimize what you don’t measure:
PostgreSQL Connection Stats
| |
PgBouncer Stats
| |
Key metrics to alert on:
- Pool exhaustion (waiting clients > 0 for extended periods)
- Connection churn (high connects/disconnects per second)
- Query wait time (time spent waiting for connection)
Common Pitfalls
Holding Connections Too Long
Bad:
| |
Connection held for entire batch. If batches take 10 seconds and you have 10 concurrent batches, you need 10 connections minimum.
Better:
| |
Not Setting Timeouts
Without timeouts, a slow query holds a connection forever:
| |
Ignoring Connection Errors
Connections fail. Handle it:
| |
The Checklist
Before deploying to production:
- Connection pooling enabled (not creating connections per request)
- Pool size tuned to workload (start with 2x CPU cores)
- Connection lifetime limited (recycle every 30-60 minutes)
- Health checks enabled (pre-ping or equivalent)
- Statement timeouts set (prevent runaway queries)
- Monitoring in place (pool utilization, wait times)
- External pooler considered for high-scale (PgBouncer)
Connection pooling is infrastructure that pays dividends on every single database request. Configure it once, correctly, and forget about it. Your p99 latency will thank you.