← Back to the library
BackendHow it works · Core · 11 min

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

Acquire, query, releaseThe acquire timeout is the only thing standing between a saturated pool and requests that hang until the load balancer gives up.
  1. AcquireCaller asks the pool for a connection (pool.connect, getConnection, begin).
  2. Reuse an idle connectionFast path. Some pools ping or test it first and discard it if the server closed it.
  3. Open new if below maxTCP, TLS, auth and a forked backend process. Only happens while the pool is under its size cap.
  4. Queue when pool is fullCaller waits FIFO for a release. Past the acquire timeout it gets an error instead of waiting forever.
  5. Run query or transactionThe connection is exclusively yours. Every statement in a transaction must use this same connection.
  6. ReleaseReturned to idle and handed to the next waiter. A broken connection is destroyed instead.
  7. Evict idle or agedIdle timeout and max lifetime close connections so the pool shrinks and survives server-side kills.
Where pooling happensTotal server connections are bounded by the bottom layers; client pools multiply by the number of processes above them.
  1. Request handlers / workersHundreds of concurrent requests, goroutines, threads or async tasks.
  2. In-process client poolpg Pool, HikariCP, database/sql, SQLAlchemy QueuePool, sqlx. One per process, per replica.
  3. External poolerPgBouncer, RDS Proxy, Neon or Supabase poolers. Thousands of client connections onto tens of server ones.
  4. Postgres postmasterAccepts connections, authenticates, forks one backend per server connection, enforces max_connections.
  5. Backend processesOne OS process per connection, each with its own memory, catalog caches and session state.
  6. 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.
node-postgres (pg) documentation (opens in new tab)

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.
Prisma ORM 7 + adapter-pg documentation (opens in new tab)

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.
database/sql + pgx stdlib documentation (opens in new tab)

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.
SQLAlchemy documentation (opens in new tab)

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.
HikariCP documentation (opens in new tab)

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.
PgBouncer documentation (opens in new tab)

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.
sqlx documentation (opens in new tab)

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?

SOURCES

Explainer reviewed