Database Connection Pooling Low-Level Design: PgBouncer and Pool Internals

Connection pooling maintains a set of pre-established database connections that are reused across requests, rather than opening and closing a connection for every query. Opening a PostgreSQL connection involves TCP handshake, TLS negotiation, authentication, and session setup — typically 20-100ms. With 1,000 requests/second, that’s 1,000 new connections/second, each consuming ~5MB of DB server memory. Connection pooling reduces this to tens of persistent connections handling millions of queries.

Core Data Model

-- Pool state tracked per-process (in-memory, not in DB)
-- Each pool manages connections to one database + user combination

class ConnectionPool:
    pool_size: int          # max total connections
    min_idle: int           # minimum idle connections to keep warm
    max_idle: int           # maximum idle connections to retain
    acquire_timeout_ms: int # how long to wait for an available connection
    idle_timeout_s: int     # close idle connections older than this
    max_lifetime_s: int     # close connections older than this (force refresh)

    _idle: deque            # available connections, LIFO for connection warmth
    _in_use: set            # connections currently held by requests
    _lock: threading.Lock   # protect shared state
    _not_empty: threading.Condition  # signal when a connection is returned

Pool Implementation

import threading
import time
from collections import deque
import psycopg2

class ConnectionPool:
    def __init__(self, dsn: str, pool_size: int = 20, min_idle: int = 5,
                 acquire_timeout_ms: int = 5000, max_lifetime_s: int = 3600):
        self.dsn = dsn
        self.pool_size = pool_size
        self.min_idle = min_idle
        self.acquire_timeout_ms = acquire_timeout_ms
        self.max_lifetime_s = max_lifetime_s
        self._idle: deque = deque()
        self._in_use: set = set()
        self._lock = threading.Lock()
        self._not_empty = threading.Condition(self._lock)
        self._total_created = 0

        # Pre-warm with min_idle connections
        for _ in range(min_idle):
            conn = self._create_connection()
            self._idle.append(conn)

        # Start background maintenance thread
        threading.Thread(target=self._maintenance_loop, daemon=True).start()

    def _create_connection(self) -> dict:
        conn = psycopg2.connect(self.dsn)
        conn.autocommit = False
        self._total_created += 1
        return {'conn': conn, 'created_at': time.monotonic(), 'last_used': time.monotonic()}

    def acquire(self) -> psycopg2.extensions.connection:
        deadline = time.monotonic() + self.acquire_timeout_ms / 1000
        with self._not_empty:
            while True:
                if self._idle:
                    # LIFO: take most recently used connection (warmer, less likely stale)
                    entry = self._idle.pop()
                    if self._is_expired(entry):
                        entry['conn'].close()
                        entry = self._create_connection()
                    self._in_use.add(id(entry['conn']))
                    entry['last_used'] = time.monotonic()
                    return entry['conn']

                total = len(self._idle) + len(self._in_use)
                if total < self.pool_size:
                    # Room to grow — create a new connection
                    entry = self._create_connection()
                    self._in_use.add(id(entry['conn']))
                    return entry['conn']

                # Pool exhausted — wait for a connection to be returned
                remaining = deadline - time.monotonic()
                if remaining  bool:
        age = time.monotonic() - entry['created_at']
        return age > self.max_lifetime_s

    def _maintenance_loop(self):
        """Periodically close excess idle connections and validate stale ones."""
        while True:
            time.sleep(30)
            with self._lock:
                now = time.monotonic()
                to_close = []
                live_idle = deque()
                for entry in self._idle:
                    age = now - entry['last_used']
                    if age > 600 and len(live_idle) >= self.min_idle:
                        to_close.append(entry['conn'])
                    else:
                        live_idle.append(entry)
                self._idle = live_idle
            for conn in to_close:
                try:
                    conn.close()
                except Exception:
                    pass

from contextlib import contextmanager

@contextmanager
def get_connection(pool: ConnectionPool):
    conn = pool.acquire()
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        pool.release(conn)

PgBouncer: Production-Grade Pooling

Don’t implement connection pooling in your application when PgBouncer exists. PgBouncer is a lightweight PostgreSQL-specific proxy that sits between your application and the database, managing pooling transparently. Three pooling modes: session (one server connection per client session — same as no pooling), transaction (server connection held only for the duration of a transaction — most efficient), statement (connection returned after each statement — cannot use multi-statement transactions).

# PgBouncer configuration (pgbouncer.ini)
[databases]
appdb = host=postgres-primary port=5432 dbname=appdb

[pgbouncer]
pool_mode = transaction          # most efficient for stateless apps
max_client_conn = 10000          # clients can open up to 10K connections to PgBouncer
default_pool_size = 25           # PgBouncer opens at most 25 connections to Postgres
min_pool_size = 5
reserve_pool_size = 5            # emergency connections for bursts
reserve_pool_timeout = 3
server_idle_timeout = 600
# Result: 10,000 app connections -> 25 Postgres connections

Key Interview Points

  • PostgreSQL has a hard limit on concurrent connections (typically 100-500). Without pooling, each app thread holds a connection — 100 servers × 50 threads = 5,000 connections, exceeding the limit. PgBouncer multiplexes 5,000 app connections onto 25 database connections.
  • LIFO eviction (take the most recently returned connection) keeps a subset of connections warm and lets idle excess connections expire naturally — better than FIFO which would age all connections equally.
  • Always rollback on release: if the caller forgets to commit or roll back, returning a connection with an open transaction poisons the next request that uses it.
  • Max lifetime prevents connections from staying open indefinitely — databases, proxies, and firewalls may silently close long-lived TCP connections. Proactively cycling connections prevents “broken pipe” errors.
  • Pool timeout errors are the signal to increase pool size or scale the database — they indicate the database cannot keep up with query throughput, not a pool implementation bug.
  • In transaction mode, PgBouncer cannot support prepared statements or advisory locks (they are session-scoped, not transaction-scoped). Disable prepared statements in your driver when using PgBouncer in transaction mode.

Database connection pooling and RDS scaling design is discussed in Amazon system design interview questions.

Database connection pooling and high-throughput system design is covered in Netflix system design interview preparation.

Database connection pooling and e-commerce backend design is discussed in Shopify system design interview guide.

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

See also: Atlassian Interview Guide

Scroll to Top