Database Connection Pooling: Sizing, HikariCP & pgBouncer
Database connection pooling from first principles: the pool sizing formula, HikariCP and SQLAlchemy configs, pgBouncer modes, and catching leaks.
Muhammad Abdul Sami
· Updated · 13 min read
- PostgreSQL
- Performance
- Backend
- APIs
- Observability
Table of Contents:
- Why Connection Pooling Matters
- How Database Connection Pools Work
- Pool Sizing: The Formula That Actually Works
- Application-Level Pooling (HikariCP, SQLAlchemy)
- External Pooling with pgBouncer
- Serverless and Connection Storms
- Monitoring and Debugging Pool Exhaustion
- Frequently Asked Questions
Why Connection Pooling Matters
Short answer: Database connection pooling reuses open connections instead of opening a new TCP session and authenticating on every query — typically cutting latency by 20–50ms per request and preventing your database from drowning under connection overhead.
Every PostgreSQL connection costs roughly 5–10MB of RAM on the server. A misconfigured API with 200 workers each holding 20 connections can exhaust a db.t3.medium instance before a single query runs. We have seen this pattern break three production launches in the last year — all fixable with correct connection pool configuration.
This guide covers database connection pooling from first principles through production tuning: sizing formulas, HikariCP and SQLAlchemy setup, pgBouncer for multi-service architectures, and the serverless patterns that prevent connection storms.
Key Takeaways:
- Pool size is almost always smaller than you think — start with
(CPU cores × 2) + spindle_count- Use application pools for low-latency OLTP; add pgBouncer when many services share one database
- Connection leaks show up as gradual latency increases, not sudden crashes
- Monitor
active_connections,pool_wait_time, andidle_in_transaction— not just query duration
Need help tuning your database layer? Our backend API engineering team optimizes connection pools as part of every production API engagement.
How Database Connection Pools Work
A connection pool maintains a set of pre-established database connections. When your application needs to run a query, it borrows a connection from the pool, executes the query, and returns the connection — rather than opening and closing a new socket each time.
The Cost of Opening a Connection
Each new PostgreSQL connection triggers:
- TCP handshake — 1–3 round trips depending on TLS
- Authentication — password verification, optional certificate exchange
- Session initialization — memory allocation, process fork on PostgreSQL
- Connection teardown — cleanup on close
On a typical AWS RDS instance in the same AZ, this overhead runs 15–40ms per connection. Under load, connection creation becomes a bottleneck that no amount of query optimization fixes.
Pool Lifecycle
Application Request
↓
Pool.acquire() → Wait if all connections busy (configurable timeout)
↓
Execute query on borrowed connection
↓
Pool.release() → Connection returns to idle set (not closed)
Critical distinction: A released connection stays open. A closed connection is destroyed and must be recreated. Pools that close connections on release defeat the purpose.
Types of Pooling
| Layer | Tool | Best For |
|---|---|---|
| Application | HikariCP, SQLAlchemy, node-pg | Single-service APIs, fine-grained control |
| External proxy | pgBouncer, RDS Proxy | Multi-service, serverless, connection multiplexing |
| ORM-managed | Django, Rails built-in | Framework defaults (often need tuning) |
For most backend API engineering projects, we start with application-level pooling and add pgBouncer when connection count exceeds 60% of max_connections.
Pool Sizing: The Formula That Actually Works
The most common connection pooling mistake is making the pool too large. More connections does not mean more throughput — PostgreSQL performance degrades past a CPU-dependent threshold because each connection competes for the same buffer pool and CPU cycles.
The PostgreSQL Formula
For OLTP workloads on PostgreSQL, start with the formula popularised by the HikariCP pool sizing guide, which is itself derived from PostgreSQL's own guidance:
pool_size = (num_cpu_cores × 2) + effective_spindle_count
On a 4-vCPU RDS instance with SSD storage:
pool_size = (4 × 2) + 1 = 9 connections per application instance
If you run 8 API replicas, total connections = 8 × 9 = 72. Set PostgreSQL max_connections to at least 100 (leaving headroom for admin, migrations, and monitoring).
Why Smaller Pools Win
PostgreSQL uses MVCC (Multi-Version Concurrency Control). More concurrent connections mean more lock contention, more buffer cache churn, and more context switching. Benchmarks consistently show that 10 well-utilized connections outperform 100 idle ones.
| Pool Size (per instance) | P99 Latency | Throughput (req/s) | DB CPU |
|---|---|---|---|
| 5 | 45ms | 1,200 | 62% |
| 20 | 89ms | 980 | 91% |
| 50 | 340ms | 420 | 100% |
Benchmark: 4-vCPU RDS PostgreSQL, 8 FastAPI workers, 10,000 read queries. Results from a HinterBuild client engagement, Q2 2026.
Sizing Checklist
- Calculate per-instance pool size using the formula above
- Multiply by replica count — verify total < 70% of
max_connections - Set
connectionTimeoutto 5–10 seconds (fail fast, do not queue forever) - Set
idleTimeoutto 10–30 minutes (recycle stale connections) - Reserve 10–20 connections for migrations, admin, and monitoring tools
Deploy on cloud infrastructure with auto-scaling that respects connection limits — scaling API replicas without recalculating pool math is a common outage trigger.
Application-Level Pooling (HikariCP, SQLAlchemy)
Application-level database connection pooling gives you the lowest latency because connections live in-process. No proxy hop, no additional network round trip.
HikariCP (Java / Spring Boot)
HikariCP is the default pool in Spring Boot 2+ for good reason — it is fast and exposes the metrics you need. Framework choice affects how much pooling you get for free; our FastAPI vs Gin vs Express comparison covers the defaults.
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 5000 # ms — fail fast
idle-timeout: 600000 # 10 min
max-lifetime: 1800000 # 30 min — rotate before DB timeout
leak-detection-threshold: 60000 # warn if connection held > 60s
pool-name: orders-api-pool
leak-detection-threshold is non-negotiable in production. It logs a stack trace when a connection is borrowed longer than the threshold — the fastest way to find code paths that forget to close connections.
SQLAlchemy (Python / FastAPI)
For Python APIs, SQLAlchemy's QueuePool is the standard (all options are documented in the SQLAlchemy connection pooling reference):
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool
DATABASE_URL = "postgresql+asyncpg://user:pass@db.internal:5432/orders"
engine = create_async_engine(
DATABASE_URL,
poolclass=QueuePool,
pool_size=10, # persistent connections
max_overflow=5, # burst capacity (total max = 15)
pool_timeout=5, # seconds to wait for a connection
pool_recycle=1800, # recycle connections every 30 min
pool_pre_ping=True, # verify connection alive before use
echo_pool=False, # set True during debugging
)
AsyncSessionLocal = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async def get_db():
"""FastAPI dependency — always closes session in finally block."""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
# session closed automatically by context manager
pool_pre_ping prevents the classic "connection closed by server" error after idle periods. RDS and managed databases routinely terminate idle connections after 30–60 minutes.
Node.js (node-postgres)
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST,
database: 'orders',
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 10, // maximum pool size
idleTimeoutMillis: 30000, // close idle connections after 30s
connectionTimeoutMillis: 5000,
});
// Always use pool.query() or release client in finally
async function getOrder(orderId) {
const client = await pool.connect();
try {
const result = await client.query(
'SELECT * FROM orders WHERE id = $1',
[orderId]
);
return result.rows[0];
} finally {
client.release(); // CRITICAL — never skip this
}
}
Our backend API engineering team ships every API with pool configuration documented in the OpenAPI spec and a runbook for scaling events.
External Pooling with pgBouncer
When multiple services share one PostgreSQL instance — or when you run serverless functions — application-level pools multiply. Eight services × 10 connections each = 80 connections before user traffic arrives. pgBouncer sits between applications and PostgreSQL, multiplexing many client connections onto fewer server connections.
pgBouncer Pool Modes
| Mode | Behavior | Use Case |
|---|---|---|
| Session | Connection assigned for entire client session | Migrations, prepared statements, LISTEN/NOTIFY |
| Transaction | Connection returned after each transaction | Default for most OLTP APIs |
| Statement | Connection returned after each statement | Read-heavy, no transactions |
Transaction mode is correct for 90% of REST APIs. Session mode is required if you use PostgreSQL advisory locks or temporary tables that span multiple queries in one session, and it is also what queue workers built on FOR UPDATE SKIP LOCKED need when they hold a row lock across several statements. The full list of settings is in the pgBouncer configuration docs.
pgBouncer Configuration
; pgbouncer.ini [databases] orders = host=postgres.internal port=5432 dbname=orders [pgbouncer] listen_addr = 0.0.0.0 listen_port = 6432 auth_type = md5 auth_file = /etc/pgbouncer/userlist.txt pool_mode = transaction max_client_conn = 1000 ; clients can connect default_pool_size = 20 ; actual PostgreSQL connections per db/user min_pool_size = 5 reserve_pool_size = 5 ; emergency burst pool reserve_pool_timeout = 3 server_idle_timeout = 600 server_lifetime = 3600
Point all application connection strings to pgBouncer (port 6432) instead of PostgreSQL directly. Reduce each application's pool_size since pgBouncer handles multiplexing.
When to Add pgBouncer
Add external pooling when:
- Total application connections exceed 60% of
max_connections - You deploy serverless functions (Lambda, Cloud Functions) that cannot maintain persistent pools
- You run 10+ microservices against one database
- Connection count spikes during deploys (old + new pods both running); see zero-downtime deployments for budgeting the overlap
For Kubernetes platform engineering deployments, we run pgBouncer as a sidecar or dedicated deployment with health checks wired into the cluster autoscaler.
Serverless and Connection Storms
Serverless environments break traditional connection pooling because each function invocation may create a new pool — or worse, a new connection. A traffic spike with 500 concurrent Lambda invocations can open 500 PostgreSQL connections simultaneously.
The Connection Storm Pattern
Traffic spike → 500 Lambda invocations
→ 500 new connection attempts
→ PostgreSQL max_connections exceeded
→ All queries fail with "too many connections"
→ Retries amplify the storm
We debugged this exact failure for a fintech client during their product launch. The fix took 45 minutes once identified; the outage lasted 3 hours because retries made it worse.
Serverless Strategies
1. RDS Proxy or pgBouncer (recommended)
AWS RDS Proxy maintains a warm connection pool and handles IAM authentication. Point Lambda functions at the proxy endpoint, not RDS directly.
# Lambda — minimal pool, proxy handles multiplexing
engine = create_async_engine(
os.environ["RDS_PROXY_URL"],
pool_size=1, # one connection per warm container
max_overflow=0,
pool_pre_ping=True,
)
2. Global pool singleton with module-level initialization
Initialize the pool outside the handler so warm containers reuse it:
# pool lives at module scope — reused across invocations
_engine = create_async_engine(DATABASE_URL, pool_size=2, max_overflow=1)
def handler(event, context):
# uses _engine, does not create new pool
...
3. Data API for low-frequency access
AWS RDS Data API uses HTTP instead of persistent connections. Higher per-query latency (~10–20ms overhead) but zero connection management. Suitable for admin tasks and low-traffic CRUD, not high-throughput OLTP.
Deploy serverless database access patterns through our cloud infrastructure and DevOps practice — including RDS Proxy configuration and connection budgeting per function.
Monitoring and Debugging Pool Exhaustion
You cannot tune what you cannot see. Connection pool monitoring should be on your dashboard before launch, not after the first outage.
Metrics That Matter
| Metric | Source | Alert Threshold |
|---|---|---|
hikaricp.connections.active | HikariCP JMX/Micrometer | > 80% of max pool size for 5 min |
hikaricp.connections.pending | HikariCP | > 0 for 30 seconds (requests waiting) |
pg_stat_activity.count | PostgreSQL | > 70% of max_connections |
idle_in_transaction | PostgreSQL | > 5 for 2 min (likely leak or slow transaction) |
| Pool wait time P99 | Application metrics | > 100ms |
PostgreSQL Diagnostic Queries
Everything below reads from pg_stat_activity, documented in the PostgreSQL monitoring statistics reference. Pair these with the query-level checks in PostgreSQL performance secrets developers miss.
-- Active connections by application
SELECT application_name, state, count(*)
FROM pg_stat_activity
WHERE datname = 'orders'
GROUP BY application_name, state
ORDER BY count DESC;
-- Long-running idle-in-transaction (connection leak indicator)
SELECT pid, application_name, state,
now() - xact_start AS transaction_duration,
query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - xact_start > interval '30 seconds'
ORDER BY transaction_duration DESC;
-- Connection count vs limit
SELECT count(*) AS current,
(SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max
FROM pg_stat_activity;
Connection Leak Detection
The most common leak pattern: a code path that acquires a connection but never releases it on an exception branch.
# BAD — connection leaked on exception
async def process_order(order_id: str):
session = AsyncSessionLocal()
order = await session.get(Order, order_id)
await external_api.charge(order) # if this throws, session never closed
await session.commit()
await session.close()
# GOOD — context manager guarantees release
async def process_order(order_id: str):
async with AsyncSessionLocal() as session:
order = await session.get(Order, order_id)
await external_api.charge(order)
await session.commit()
Implement full pool and database observability with our observability and monitoring services — including Grafana dashboards for HikariCP, pgBouncer, and PostgreSQL connection metrics.
Production Case Study: E-Commerce API Pool Tuning
A retail client running FastAPI on 12 Kubernetes pods had configured pool_size=25 per pod — 300 total connections against a 4-vCPU RDS instance with max_connections=200.
Symptoms: P99 latency climbed from 80ms to 2.4 seconds over two weeks. No single slow query — the database was spending 40% of CPU on connection management.
Fix:
- Reduced per-pod
pool_sizefrom 25 to 8 (max_overflow=2) - Added pgBouncer in transaction mode with
default_pool_size=30 - Enabled
leak-detection-threshold=30000in HikariCP equivalent (SQLAlchemy event listeners) - Found three endpoints holding connections during 30-second external API calls — moved external calls outside the DB transaction
Result: P99 latency dropped to 65ms. Database CPU utilization fell from 91% to 58%. Zero connection exhaustion events in 4 months post-fix.
Frequently Asked Questions
What is database connection pooling?
Database connection pooling maintains a cache of open database connections that applications reuse across requests, avoiding the overhead of creating and destroying connections for every query.
How big should my connection pool be?
Start with (CPU cores × 2) + 1 per application instance. Multiply by replica count and verify the total stays below 70% of PostgreSQL max_connections. Most production APIs need 5–15 connections per instance, not 50–100.
What is the difference between HikariCP and pgBouncer?
HikariCP is an in-process application pool — lowest latency, one pool per service instance. pgBouncer is an external proxy that multiplexes connections from many clients onto fewer PostgreSQL connections — essential for multi-service or serverless architectures.
Why am I getting "too many connections" errors?
Your total connections (all services × pool size × replicas) exceeds PostgreSQL max_connections. Fix by reducing per-instance pool size, adding pgBouncer or RDS Proxy, or increasing max_connections (which also increases memory requirements).
Should I use RDS Proxy or pgBouncer?
RDS Proxy integrates with AWS IAM auth, handles failover automatically, and requires zero infrastructure management — best for AWS-native serverless. pgBouncer is more configurable, runs anywhere, and supports finer-grained pool modes — best for multi-cloud or Kubernetes deployments.
What is idle_in_transaction and why is it dangerous?
An idle_in_transaction connection has an open transaction but is not executing queries. It holds row locks, prevents vacuum from reclaiming dead tuples, and consumes a pool slot. Usually caused by application code that opens a transaction and then waits on an external API call.
Does connection pooling work with read replicas?
Yes. Create separate pools for writer and reader endpoints. Size read pools based on read replica CPU count using the same formula. Route read-only queries to the read pool; never share a pool across writer and reader endpoints.
How do I test my pool configuration under load?
Use k6, locust, or wrk to simulate production traffic patterns. Monitor pool active count, pending requests, and PostgreSQL connection count during the test. Ramp gradually — connection storms often appear at 3–5× normal traffic, not at baseline.
Conclusion
Database connection pooling is not a set-and-forget configuration. It requires deliberate sizing, layer selection (application vs external proxy), and continuous monitoring.
The patterns that survive production:
- Size pools using the CPU formula — smaller is almost always better
- Enable leak detection from day one
- Add pgBouncer or RDS Proxy before connection count becomes a problem
- Monitor
idle_in_transactionand pool wait time, not just query latency - Never hold database connections during external API calls (one of the API design mistakes that kill performance)
At HinterBuild:
- Backend & API Engineering — pool configuration, query optimization, zero-downtime migrations
- Cloud Infrastructure & DevOps — RDS Proxy, pgBouncer deployment, connection budgeting
- Observability & Monitoring — pool metrics dashboards and alerting
- Kubernetes & Platform Engineering — sidecar pooling patterns for microservices
Schedule a consultation for a database performance review.
Free consultation
Book a free consultation call on database connection pooling & performance
30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.
Book a meeting
Resources:
Keep reading
Related articles
API Design Mistakes That Kill Performance: Fixes That Work
Eight API design mistakes that kill performance — N+1 calls, over-fetching, offset pagination, sync side effects — with Go, Python, and TypeScript fixes.
Read post
Multi-Tenancy Patterns: Database Strategies for SaaS
Learn multi-tenancy patterns through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
Database Schema Migrations Without Downtime
Database Schema Migrations Without Downtime guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable.
Read post
Database Schema Design for AI Applications
Database Schema Design for AI Applications guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable.
Read post
