Database connection pooling
What actually happens when your code asks for a database connection?
A pool keeps a bounded set of expensive, authenticated database sessions open and lends them out per query or transaction, so callers queue for a connection instead of each opening their own.
THE MENTAL MODEL
A Postgres connection is not a socket, it is a process: the postmaster forks a backend for each one, after a TCP handshake, usually TLS, and SCRAM authentication. That costs milliseconds and megabytes, and the server only tolerates a few hundred of them. A pool turns that into a small, fixed fleet of warm sessions. Code acquires one, runs a statement or a transaction, and releases it; when all are busy, callers wait in a queue with a timeout. The pool size is a cap on database concurrency, not a throughput knob: the database does its best work with roughly a few connections per core, and everything beyond that is better spent waiting in your queue than contending inside Postgres. External poolers such as PgBouncer or RDS Proxy add a second layer that multiplexes many client connections onto few server ones, which is what makes serverless and many-replica deployments survivable, at the price of session state no longer belonging to you.
HOW IT FITS TOGETHER
- AcquireCaller asks the pool for a connection (pool.connect, getConnection, begin).
- Reuse an idle connectionFast path. Some pools ping or test it first and discard it if the server closed it.
- Open new if below maxTCP, TLS, auth and a forked backend process. Only happens while the pool is under its size cap.
- Queue when pool is fullCaller waits FIFO for a release. Past the acquire timeout it gets an error instead of waiting forever.
- Run query or transactionThe connection is exclusively yours. Every statement in a transaction must use this same connection.
- ReleaseReturned to idle and handed to the next waiter. A broken connection is destroyed instead.
- Evict idle or agedIdle timeout and max lifetime close connections so the pool shrinks and survives server-side kills.
- Request handlers / workersHundreds of concurrent requests, goroutines, threads or async tasks.
- In-process client poolpg Pool, HikariCP, database/sql, SQLAlchemy QueuePool, sqlx. One per process, per replica.
- External poolerPgBouncer, RDS Proxy, Neon or Supabase poolers. Thousands of client connections onto tens of server ones.
- Postgres postmasterAccepts connections, authenticates, forks one backend per server connection, enforces max_connections.
- Backend processesOne OS process per connection, each with its own memory, catalog caches and session state.
- Shared buffers, WAL, diskThe contended resources. More backends than cores mostly means more lock and context-switch contention.
KEY TERMS
- Connection setup cost
- Postgres uses a process-per-connection model: each connection is a forked backend process. Add the TCP and TLS handshakes and SCRAM authentication and opening a connection costs far more than running a typical indexed query on it.
- Max size and the queue
- The pool's max is a hard cap on concurrent database work from one process. When it is reached, callers queue. A short queue is healthy; a growing one means slow queries, leaked connections or a pool that is too small for the work.
- Acquire timeout
- How long a caller waits in the queue before failing. Defaults vary wildly: node-postgres waits forever (0), HikariCP and SQLAlchemy wait 30 s. Set it below your request timeout so exhaustion surfaces as a fast, clear error.
- Idle timeout and max lifetime
- Idle timeout closes connections nobody is using; max lifetime retires connections by age so firewalls, proxies, failovers and credential rotation do not leave the pool holding dead sockets. Keep max lifetime a few seconds below any infrastructure kill timer.
- PgBouncer pool modes
- Session mode pins a server connection for the whole client session. Transaction mode lends it per transaction, which breaks SET/RESET, LISTEN, session advisory locks, WITH HOLD cursors, temp tables kept across transactions and SQL-level PREPARE. Statement mode also forbids multi-statement transactions.
- Prepared statements behind a pooler
- Since PgBouncer 1.21, max_prepared_statements tracks protocol-level named prepared statements in transaction mode and re-prepares them on whichever server connection you land on; 1.24 made 200 the default. SQL-level PREPARE/EXECUTE is still not tracked. RDS Proxy for Postgres pins the session on PREPARE, SET and similar state changes instead.
- Sizing
- Start from the database: a pool near (cores x 2) + effective spindles per database server, then divide across replicas so replicas x pool max stays under max_connections with headroom for admin, migrations and replication. If you need more client concurrency than that, add an external pooler rather than raising the cap.
IN YOUR STACK
TypeScript · node-postgres (pg) Server
import { Pool } from "pg";
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // per process: multiply by replicas
idleTimeoutMillis: 10_000,
connectionTimeoutMillis: 3_000, // also bounds the wait in the queue
maxLifetimeSeconds: 1_800,
});
pool.on("error", (err) => console.error("idle client error", err));
// One statement: acquire, run and release are handled for you.
const { rows } = await pool.query("SELECT * FROM orders WHERE id = $1", [orderId]);
// A transaction needs one client for every statement.
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, from]);
await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, to]);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}- Defaults are max 10, idleTimeoutMillis 10 s and connectionTimeoutMillis 0, which means a saturated pool makes callers wait forever.
- Running BEGIN through pool.query sends each statement to whichever client is free, so the transaction silently splits across connections.
- A missing release() leaks the client until the pool is permanently full; release(true) destroys a client you suspect is broken.
TypeScript · Prisma ORM 7 + adapter-pg Server
import { PrismaClient } from "../prisma/generated/client";
import { PrismaPg } from "@prisma/adapter-pg";
// In Prisma 7 the pool belongs to the driver adapter, configured with pg Pool options.
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
max: 10,
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 300_000,
});
export const prisma = new PrismaClient({ adapter });- connection_limit and pool_timeout URL parameters were Prisma 6 settings; in v7 they do not configure the pg adapter's pool, whose defaults are max 10, 10 s idle and no connect timeout.
- Create one PrismaClient per process; constructing one per request or per hot reload creates a new pool each time.
- With PgBouncer 1.21+ in transaction mode, leave pgbouncer=true off, set max_prepared_statements above zero, and point the Prisma CLI at a direct URL for migrations.
Go · database/sql + pgx stdlib Server
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib"
)
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL")) // validates config, does not connect
if err != nil {
return err
}
db.SetMaxOpenConns(20) // default is unlimited
db.SetMaxIdleConns(20) // default 2 churns connections under load
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) // also bounds the acquire wait
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT id, total FROM orders WHERE user_id = $1", userID)
if err != nil {
return err
}
defer rows.Close() // an open Rows keeps its connection checked out
s := db.Stats()
log.Printf("in_use=%d idle=%d waits=%d waited=%s", s.InUse, s.Idle, s.WaitCount, s.WaitDuration)- *sql.DB is the pool; open it once and share it. There is no separate acquire timeout, so the context deadline is what stops an indefinite wait.
- Unlimited MaxOpenConns lets a traffic spike open as many connections as there are goroutines, which is how one service exhausts max_connections for everyone.
- Rising WaitCount and WaitDuration in DBStats are the earliest signal that the pool is the bottleneck.
Python · SQLAlchemy Server
from sqlalchemy import create_engine, text
from sqlalchemy.pool import NullPool
engine = create_engine(
"postgresql+psycopg://app@db/app",
pool_size=10, # connections kept open
max_overflow=5, # burst connections, closed when returned
pool_timeout=5, # seconds to wait for a checkout (default 30)
pool_recycle=1800, # replace connections older than 30 minutes
pool_pre_ping=True, # test on checkout and replace dead connections
)
with engine.connect() as conn: # checkout; returned to the pool on exit
rows = conn.execute(text("SELECT id FROM orders WHERE user_id = :u"), {"u": user_id}).all()
# Behind PgBouncer or in short-lived workers, let the external pooler pool.
pooled_engine = create_engine("postgresql+psycopg://app@pgbouncer:6432/app", poolclass=NullPool)- Default QueuePool is pool_size 5 plus max_overflow 10, so the real ceiling per process is 15, not 5.
- The pool is per process: with forking servers such as Gunicorn, call engine.dispose(close=False) in the child or create the engine after fork.
- NullPool opens and closes a real connection per checkout, which is only cheap when PgBouncer or a managed pooler is on the other end.
Java · HikariCP Server
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://db:5432/app");
config.setUsername("app");
config.setPassword(System.getenv("DB_PASSWORD"));
config.setMaximumPoolSize(10);
config.setConnectionTimeout(3_000); // ms to wait for a connection (default 30 s)
config.setMaxLifetime(1_740_000); // just under a 30 min proxy or DB kill
config.setKeepaliveTime(120_000);
config.setLeakDetectionThreshold(10_000); // log connections held longer than 10 s
HikariDataSource ds = new HikariDataSource(config);
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT total FROM orders WHERE id = ?")) {
ps.setLong(1, orderId);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) return rs.getBigDecimal("total");
}
}- minimumIdle defaults to maximumPoolSize, giving a fixed-size pool, which is what HikariCP recommends.
- In Spring Boot these map to spring.datasource.hikari.* properties such as maximum-pool-size and connection-timeout.
- A thread that holds one connection and asks for a second can deadlock the pool; size for Tn x (Cm - 1) + 1 or avoid nested acquisition.
INI · PgBouncer Server
[databases]
app = host=10.0.0.5 port=5432 dbname=app
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 2000 ; client sockets are cheap
default_pool_size = 20 ; server connections per user/database pair
reserve_pool_size = 5
max_prepared_statements = 200 ; protocol-level prepared statements in transaction mode
query_wait_timeout = 30 ; disconnect clients queued longer than this
server_idle_timeout = 600- Transaction mode skips server_reset_query: anything a client sets with SET, session advisory locks or LISTEN leaks to or disappears for the next client.
- Pools are per user/database pair, so default_pool_size x pairs is the real server connection count; keep it under max_connections.
- Run migrations, pg_dump and LISTEN consumers on a direct or session-mode connection.
Rust · sqlx Server
use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
let pool = PgPoolOptions::new()
.max_connections(10)
.min_connections(2)
.acquire_timeout(Duration::from_secs(3))
.idle_timeout(Duration::from_secs(600))
.max_lifetime(Duration::from_secs(1800))
.connect(&std::env::var("DATABASE_URL")?)
.await?;
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM orders WHERE user_id = $1")
.bind(user_id)
.fetch_one(&pool)
.await?;
let mut tx = pool.begin().await?; // holds one connection until commit or rollback
sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE id = $2")
.bind(amount).bind(from)
.execute(&mut *tx)
.await?;
tx.commit().await?; // dropping tx without commit rolls back- Pool is reference-counted; clone it into handlers instead of creating one per request.
- test_before_acquire defaults to true, adding a round trip per checkout; it can be disabled when max_lifetime and idle_timeout already retire stale connections.
- sqlx uses named protocol-level prepared statements, so behind PgBouncer transaction mode it needs max_prepared_statements above zero.
WHERE IT BITES
- Sizing each service's pool in isolation: 12 replicas x pool max 20 is 240 server connections, before workers, cron jobs and migrations join in.
- Leaving the acquire timeout at infinity, so pool exhaustion shows up as slow requests and upstream 504s instead of a clear pool error.
- Holding a connection, often inside an open transaction, while calling an external HTTP API; the pool drains at the speed of the slowest dependency.
- Relying on session state (SET search_path, session advisory locks, LISTEN, temp tables) through PgBouncer transaction mode or RDS Proxy, where it either leaks to other clients or pins the connection.
- Opening a pool per serverless invocation or per request, which turns pooling back into connect-per-query; use a module-level client plus an external pooler.
- Running schema migrations through a transaction-mode pooler; they need a direct, session-level connection.
CLOSE THE AI. EXPLAIN THIS.
Your service has 8 replicas with a pool max of 25 against a Postgres with max_connections = 200. What fails first under load, how would you see it, and which two numbers would you change?WHEN IT BREAKS IN PRODUCTION
SOURCES
- PostgreSQL 18: How Connections Are Established (opens in new tab)Checked
- PgBouncer configuration (opens in new tab)Checked
- PgBouncer features: SQL feature map for pooling modes (opens in new tab)Checked
- HikariCP wiki: About Pool Sizing (opens in new tab)Checked
- Amazon RDS Proxy: Avoiding pinning (opens in new tab)Checked
- Prisma ORM 7: Connection pool (opens in new tab)Checked
Explainer reviewed