Connection Pooling
The Problem
Every database connection is expensive. It involves a TCP handshake, authentication, and memory allocation on the DB server (~5-10MB per connection in PostgreSQL).
When you have thousands of concurrent users, the database drowns -- not from queries,
but from managing connections. Eventually it hits max_connections and rejects everyone.
What is a Connection Pool?
Instead of open/query/close for every request, a pool keeps a set of connections alive and reuses them.
Without pooling:
Request 1 → open connection → query → close
Request 2 → open connection → query → close (expensive every time)
With pooling:
Pool holds 20 open connections
Request 1 → borrow connection → query → return to pool
Request 2 → borrow connection → query → return to pool (reuse, no setup cost)
Application-Level Pooling
Most frameworks have built-in pools.
Python (SQLAlchemy)
from sqlalchemy import create_engine
# pool_size: number of persistent connections kept open
# max_overflow: extra connections allowed beyond pool_size under load
# pool_timeout: seconds to wait for a connection before raising an error
# pool_recycle: seconds before a connection is recycled (helps with DB-side timeouts)
engine = create_engine(
"postgresql://user:pass@localhost:5432/mydb",
pool_size=20,
max_overflow=10,
pool_timeout=30,
pool_recycle=1800,
)
# Usage -- the engine handles borrowing/returning automatically
with engine.connect() as conn:
result = conn.execute(text("SELECT * FROM users WHERE id = :id"), {"id": 42})
Go (pgxpool)
package main
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
config, _ := pgxpool.ParseConfig("postgresql://user:pass@localhost:5432/mydb")
// Pool configuration
config.MaxConns = 20 // maximum number of connections in the pool
config.MinConns = 5 // minimum idle connections kept open
config.MaxConnLifetime = 30 * time.Minute // recycle connections after this
config.MaxConnIdleTime = 5 * time.Minute // close idle connections after this
pool, _ := pgxpool.NewWithConfig(context.Background(), config)
defer pool.Close()
// Usage -- pool.Query borrows a connection, uses it, returns it
var name string
err := pool.QueryRow(context.Background(),
"SELECT name FROM users WHERE id = $1", 42,
).Scan(&name)
fmt.Println(name, err)
}
The N * X Problem: Why App Pools Aren't Enough
Each app server manages its pool independently. No server knows about the others.
App Server 1 (pool: 20) ──→ 20 connections ──┐
App Server 2 (pool: 20) ──→ 20 connections ──┤
App Server 3 (pool: 20) ──→ 20 connections ──┼──→ Database
... │
App Server 50 (pool: 20) ──→ 20 connections ──┘ = 1,000 connections!
Most of those connections are idle most of the time, but they're unavailable to other servers that might need them.
graph LR
A1[App Server 1<br/>pool: 20] -->|20 conns| DB[(Database)]
A2[App Server 2<br/>pool: 20] -->|20 conns| DB
A3[App Server 3<br/>pool: 20] -->|20 conns| DB
A4[...<br/>App Server N] -->|20 conns| DB
A5[App Server 50<br/>pool: 20] -->|20 conns| DB
DB -.- T["50 × 20 = 1,000 connections<br/>most sitting idle"]
style T fill:none,stroke:none
style DB fill:#4a1a1a,stroke:#ff6666
Proxy-Level Pooling (PgBouncer / ProxySQL)
A proxy sits between all app servers and the database. It multiplexes many app connections onto fewer real DB connections.
App Server 1 (pool: 5) ──┐
App Server 2 (pool: 5) ──┤
App Server 3 (pool: 5) ──┼──→ PgBouncer (pool: 30) ──→ Database (30 real connections)
... │
App Server 50 (pool: 5) ──┘
250 app connections funneled into 30 real connections
This works because not all 250 connections are actively querying at the same instant. PgBouncer borrows a real connection only for the duration of a query/transaction, then releases it for someone else.
graph LR
A1[App Server 1<br/>pool: 5] --> PG[PgBouncer<br/>pool: 30]
A2[App Server 2<br/>pool: 5] --> PG
A3[App Server 3<br/>pool: 5] --> PG
A4[...<br/>App Server N] --> PG
A5[App Server 50<br/>pool: 5] --> PG
PG -->|30 real conns| DB[(Database)]
PG -.- T["250 app conns → 30 DB conns<br/>~88% reduction"]
style T fill:none,stroke:none
style PG fill:#1a3a1a,stroke:#66ff66
PgBouncer Configuration (pgbouncer.ini)
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
; Pool mode:
; session = one backend per client session (least savings, most compatible)
; transaction = backend shared after each transaction (good balance)
; statement = backend shared after each statement (aggressive, breaks multi-stmt txns)
pool_mode = transaction
; Pool sizing
default_pool_size = 30 ; real DB connections per pool
max_client_conn = 1000 ; max app connections accepted
reserve_pool_size = 5 ; extra connections for burst
graph TD
subgraph "Session Mode"
S1[Client connects] --> S2[Gets dedicated<br/>backend conn]
S2 --> S3[Keeps it for<br/>entire session]
S3 --> S4[Released on<br/>disconnect]
end
subgraph "Transaction Mode"
T1[Client sends<br/>BEGIN] --> T2[Gets backend<br/>conn]
T2 --> T3[Keeps it for<br/>one transaction]
T3 --> T4[Released on<br/>COMMIT/ROLLBACK]
end
subgraph "Statement Mode"
Q1[Client sends<br/>query] --> Q2[Gets backend<br/>conn]
Q2 --> Q3[Keeps it for<br/>one statement]
Q3 --> Q4[Released<br/>immediately]
end
App Connects to PgBouncer Instead of DB Directly
The only change in your app: point at PgBouncer's port instead of Postgres.
# Python -- just change the port
engine = create_engine(
"postgresql://user:pass@localhost:6432/mydb", # 6432 = PgBouncer, not 5432
pool_size=5, # smaller app pool since PgBouncer handles the real pooling
)
// Go -- same, just change the port
config, _ := pgxpool.ParseConfig("postgresql://user:pass@localhost:6432/mydb")
config.MaxConns = 5 // smaller app pool
App Pool + Proxy Pool Together
In practice most production setups use both:
- Small app pool (5-10) for local efficiency (avoids per-request connection to proxy)
- Proxy pool (30-100) for global consolidation across all app servers
PgBouncer vs ProxySQL
| Aspect | PgBouncer | ProxySQL |
|---|---|---|
| For which DB? | PostgreSQL only | MySQL/MariaDB only |
| Primary job | Connection pooling | Pooling + routing + more |
| Complexity | Simple, lightweight | Feature-rich, complex |
| Extra features | Minimal | Query routing, caching, rewriting |
| Config | INI file | SQL-like admin interface |
They are NOT competitors -- they serve different database ecosystems.
Other Similar Tools
- Odyssey -- PostgreSQL pooler by Yandex, supports multithreading (PgBouncer is single-threaded)
- pgcat -- PostgreSQL pooler in Rust, supports sharding
- MySQL Router -- Oracle's official MySQL proxy
- Amazon RDS Proxy -- AWS managed pooler for RDS databases