Back to Connection Pooling
Q5Question 5 of 6

What does an external connection pooler like PgBouncer add on top of the application-side pool?

Key Concepts
  • PgBouncer is a lightweight external connection pooler that sits between applications and PostgreSQL, multiplexing thousands of client connections onto a few dozen real database connections.
  • An application-side pool like HikariCP bounds one instance's connections, but PgBouncer bounds connections across the entire fleet at a single choke point.
  • In transaction-pooling mode, PgBouncer assigns a real connection only for the duration of a transaction and reclaims it on commit or rollback.
  • A few dozen real connections can serve thousands of logical clients because clients spend most of their time idle between transactions.
  • Transaction pooling breaks features relying on session state spanning transactions: prepared statements, SET variables, advisory locks, and LISTEN/NOTIFY.
  • The recommended topology is a small HikariCP pool in each application for local efficiency plus PgBouncer in front of the database for fleet-wide protection.
  • PgBouncer is a single lightweight process that scales to tens of thousands of client connections cheaply.

Definition

PgBouncer multiplexes thousands of client connections onto a few dozen real database connections at one central choke point, which an application-side pool cannot do across the whole fleet.
  • PgBouncer – A lightweight external connection pooler that sits between applications and PostgreSQL, accepting many client connections and mapping them to a small set of real database connections.
  • Fleet-wide pooling – Unlike HikariCP which bounds one instance, PgBouncer bounds the total connections across the entire fleet at a single choke point.
  • Transaction pooling – The most powerful mode: a real connection is lent only for the duration of a single transaction, then reclaimed for the next client.
  • Key trade-off – Transaction pooling breaks session-state features (prepared statements, SET variables, advisory locks, LISTEN/NOTIFY) that span transactions.

The National Bank of India has many branches, each already limiting its own phone lines. But head office worries that all branches together might overwhelm the central ledger's hundred lines. So they install one switchboard operator in the middle. Branches call the switchboard, which holds just forty real lines to the ledger. When a branch needs to speak to the ledger for a transaction, the operator lends it a real line, then takes it back the instant the branch finishes, so forty real lines quietly serve thousands of branch calls.

Understanding the Concepts

What is PgBouncer?

PgBouncer is a lightweight external connection pooler that sits between your applications and PostgreSQL. Every application points its JDBC URL at PgBouncer instead of directly at PostgreSQL. PgBouncer holds a small fixed set of real connections to PostgreSQL, so no matter how many clients connect, the database only ever sees that small number.

DIAGRAM
Without PgBouncer:
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ App 1  │ │ App 2  │ │ App 3  │ │ App N  │
│ pool=15│ │ pool=15│ │ pool=15│ │ pool=15│
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
    │          │          │          │
    └────┬─────┴────┬─────┴────┬─────┘
         │          │          │
    ┌────┴──────────┴──────────┴────┐
    │  PostgreSQL (max_connections)  │
    │  N × 15 connections demanded   │
    └───────────────────────────────┘

With PgBouncer:
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ App 1  │ │ App 2  │ │ App 3  │ │ App N  │
│ pool=10│ │ pool=10│ │ pool=10│ │ pool=10│
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
    │          │          │          │
    └────┬─────┴────┬─────┴────┬─────┘
         │          │          │
    ┌────┴──────────┴──────────┴────┐
    │        PgBouncer               │
    │  Accepts 1000s of clients      │
    │  Holds only 40 real connections│
    └────────────┬───────────────────┘
                 │
    ┌────────────┴───────────────────┐
    │  PostgreSQL (40 connections)    │
    └────────────────────────────────┘

Why is an application-side pool not enough?

An application-side pool like HikariCP is excellent but local: it caps the connections of one application instance. When you scale to many instances or have many different services hitting the same database, the sum of their pools can exceed the database's limit.

JAVA
package com.bank.config;

public class FleetConnectionMath {

    public static void main(String[] args) {
        // Without PgBouncer:
        int services = 5;          // account-service, loan-service, etc.
        int instancesPerService = 4;
        int poolPerInstance = 15;
        int totalConnections = services * instancesPerService * poolPerInstance;
        System.out.println("Total connections without PgBouncer: " + totalConnections);
        // Output: 300 connections demanded

        int maxConnections = 200;
        System.out.println("Exceeds max_connections? " + (totalConnections > maxConnections));
        // Output: true → connections rejected!

        // With PgBouncer:
        int pgBouncerPoolSize = 40;
        System.out.println("PgBouncer real connections: " + pgBouncerPoolSize);
        // 40 real connections serve 300+ logical connections
    }
}
TEXT
Total connections without PgBouncer: 300
Exceeds max_connections? true
PgBouncer real connections: 40

How does transaction pooling mode work?

In transaction-pooling mode, PgBouncer assigns a real connection to a client only for the duration of a single transaction. The moment the transaction commits or rolls back, PgBouncer reclaims the real connection and returns it to the shared set for the next client.

TEXT
Transaction pooling timeline:

Time 0.000s: Client A starts transaction
             PgBouncer assigns real connection R1 to Client A
Time 0.020s: Client A commits
             PgBouncer takes R1 back to the shared pool

Time 0.021s: Client B starts transaction
             PgBouncer assigns R1 to Client B (reused!)
Time 0.045s: Client B commits
             PgBouncer takes R1 back again

Between transactions, clients hold NO real connection
R1 served both Client A and Client B in 45ms
With 40 real connections, thousands of clients are served
because most clients are idle between transactions
JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import javax.sql.DataSource;

public class TransferService {

    // JDBC URL points to PgBouncer, not directly to PostgreSQL
    // jdbc:postgresql://pgbouncer.nbi.internal:6432/nbi_ledger
    private final DataSource dataSource;

    public TransferService(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void transfer(String from, String to, BigDecimal amount) throws SQLException {
        try (Connection conn = dataSource.getConnection()) {
            conn.setAutoCommit(false);
            try {
                // PgBouncer assigns a real connection for this transaction
                execute(conn, "UPDATE accounts SET balance = balance - ? WHERE account_id = ?",
                        amount, from);
                execute(conn, "UPDATE accounts SET balance = balance + ? WHERE account_id = ?",
                        amount, to);
                conn.commit();
                // PgBouncer reclaims the real connection here
            } catch (SQLException e) {
                conn.rollback();
                throw e;
            }
        }
    }

    private void execute(Connection conn, String sql, BigDecimal amount, String id)
            throws SQLException {
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setBigDecimal(1, amount);
            ps.setString(2, id);
            ps.executeUpdate();
        }
    }
}

What are the different pooling modes?

PgBouncer offers three modes with different trade-offs.

TEXT
PgBouncer pooling modes:

1. Session pooling
   - Client keeps its real connection for the whole session
   - Behaves like a simple proxy
   - All PostgreSQL features work
   - Minimal multiplexing benefit

2. Transaction pooling (recommended)
   - Real connection assigned per transaction
   - Maximum multiplexing: 40 real connections serve 1000s
   - Breaks session-state features spanning transactions

3. Statement pooling
   - Real connection assigned per statement
   - Even more aggressive multiplexing
   - Breaks multi-statement transactions
   - Rarely used

What breaks with transaction pooling?

Transaction pooling breaks features that rely on session state spanning multiple transactions, because consecutive transactions may land on different real connections.

JAVA
package com.bank.service;

import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;

public class TransactionPoolingCaveats {

    private final DataSource dataSource;

    public TransactionPoolingCaveats(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    // BREAKS: Session-level prepared statements
    // Transaction 1 prepares a statement on real connection R1
    // Transaction 2 may run on real connection R2
    // The prepared statement does not exist on R2

    // BREAKS: SET session variables
    public void setTimezoneAndQuery() throws SQLException {
        try (Connection conn = dataSource.getConnection()) {
            conn.createStatement().execute("SET timezone = 'Asia/Kolkata'");
            // Transaction commits, PgBouncer reclaims connection
        }
        try (Connection conn = dataSource.getConnection()) {
            // May get a DIFFERENT real connection!
            // timezone is NOT 'Asia/Kolkata' on this connection
            conn.createStatement().executeQuery("SELECT now()");
        }
    }

    // BREAKS: Advisory locks held across transactions
    // BREAKS: LISTEN/NOTIFY channels

    // FIX: Disable server-side prepared statements in the JDBC driver
    // jdbc:postgresql://pgbouncer:6432/nbi_ledger?prepareThreshold=0
}

How do you configure PgBouncer?

PgBouncer is configured through a simple INI file.

TEXT
; /etc/pgbouncer/pgbouncer.ini

[databases]
nbi_ledger = host=pg-primary.nbi.internal port=5432 dbname=nbi_ledger

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction          ; transaction pooling
max_client_conn = 2000           ; accept up to 2000 client connections
default_pool_size = 40           ; only 40 real connections to PostgreSQL
reserve_pool_size = 5            ; extra connections for burst
reserve_pool_timeout = 3         ; wait 3s before using reserve
server_lifetime = 3600           ; recycle real connections after 1 hour
server_idle_timeout = 600        ; close idle real connections after 10 min
JAVA
package com.bank.config;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;

public class PgBouncerDataSourceConfig {

    public DataSource createPool() {
        HikariConfig config = new HikariConfig();
        // Point to PgBouncer, not directly to PostgreSQL
        config.setJdbcUrl("jdbc:postgresql://pgbouncer.nbi.internal:6432/nbi_ledger"
                + "?prepareThreshold=0");  // Disable server-side prepared statements
        config.setUsername("nbi_app");
        config.setPassword("secret");
        config.setMaximumPoolSize(10);  // Small local pool for efficiency
        config.setConnectionTimeout(3000);
        return new HikariDataSource(config);
    }
}

The prepareThreshold=0 disables server-side prepared statements so they work correctly with transaction pooling.

The recommended topology is a small HikariCP pool in each application for local efficiency, plus PgBouncer in front of the database to bound the fleet.

DIAGRAM
Recommended topology at National Bank of India:

┌────────────────────────────────────────────┐
│ account-service (4 instances × 10 pool)    │
│ loan-service    (3 instances × 10 pool)    │
│ report-service  (2 instances × 10 pool)    │
│ Total logical: 90 client connections       │
└──────────────────────┬─────────────────────┘
                       │
              ┌────────┴────────┐
              │   PgBouncer     │
              │ 2000 clients OK │
              │ 40 real to PG   │
              └────────┬────────┘
                       │
              ┌────────┴────────┐
              │  PostgreSQL     │
              │  max_conn=60    │
              │  Only sees 40   │
              └─────────────────┘

Each application has local HikariCP pools for connection reuse and validation. PgBouncer multiplexes all of them onto 40 real connections.

What should a senior engineer know?

A senior engineer should know that PgBouncer is extremely lightweight: a single process that handles tens of thousands of client connections with minimal memory. It is also critical infrastructure, so it needs monitoring, health checks, and possibly a pair running in active-passive for high availability. PgBouncer also smooths failovers because clients reconnect to PgBouncer rather than directly to the database, and PgBouncer can be reconfigured to point to a new primary.

TEXT
Senior checklist for PgBouncer:
1. Use transaction pooling mode (not session)
2. Disable server-side prepared statements (prepareThreshold=0)
3. Keep local HikariCP pools small (10 per instance)
4. Monitor PgBouncer with SHOW STATS / SHOW POOLS
5. Set up health checks (pgbouncer admin console)
6. Consider active-passive pair for HA
7. Test failover: reconfigure PgBouncer to new primary
8. Watch for session-state features that break

Visual Overview

DIAGRAM
┌────────────────────────────────────────────────────────────┐
│        PgBouncer: Transaction Pooling Mode                │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  Client A ──┐                                              │
│  Client B ──┤     ┌─────────────┐     ┌────────────┐     │
│  Client C ──┼────→│  PgBouncer  │────→│ PostgreSQL │     │
│  ...        │     │             │     │            │     │
│  Client N ──┘     │ 2000 client │     │ 40 real    │     │
│                    │ connections │     │ connections│     │
│                    └─────────────┘     └────────────┘     │
│                                                            │
│  Transaction pooling:                                      │
│  ┌────────────────────────────────────────────────────┐   │
│  │ Client A: BEGIN ... COMMIT → gets R1 for 20ms     │   │
│  │ R1 returned to shared pool                         │   │
│  │ Client B: BEGIN ... COMMIT → gets R1 for 30ms     │   │
│  │ R1 returned to shared pool                         │   │
│  │ Between transactions: client holds NO real conn    │   │
│  └────────────────────────────────────────────────────┘   │
│                                                            │
│  Breaks: Prepared stmts, SET vars, Advisory locks,        │
│          LISTEN/NOTIFY across transactions                 │
│  Fix:    prepareThreshold=0, keep state within txn        │
└────────────────────────────────────────────────────────────┘

Key Takeaway

PgBouncer is a lightweight external connection pooler that sits between applications and PostgreSQL and does something an application-side pool fundamentally cannot: it bounds the total connections across the entire fleet at one central point. A pool like HikariCP only caps the connections of a single instance, so when you scale to many instances and many services, their pools can still sum past the database's limit. PgBouncer fixes that by being a shared choke point: every application connects to PgBouncer, and PgBouncer holds a small fixed set of real connections to PostgreSQL, so the database only ever sees that small number.

The power comes from transaction-pooling mode. Instead of giving a client a real connection for its whole session, PgBouncer lends a real connection only for the duration of a single transaction and reclaims it the instant the transaction commits or rolls back. Because clients spend most of their time idle between transactions, a few dozen real connections can serve thousands of logical clients. That is how a system with thousands of connections runs against a database configured for forty.

The benefits are bounding total connections centrally, slashing PostgreSQL's per-connection memory and process overhead, absorbing connection spikes, and smoothing failovers. The caveat a senior engineer must know is that transaction pooling breaks anything relying on session state spanning transactions: session-level prepared statements, SET variables, advisory locks, and LISTEN/NOTIFY, because consecutive transactions may land on different real connections. The fix is disabling server-side prepared statements in the JDBC driver with prepareThreshold=0.

At the National Bank of India, Pankaj ran dozens of services against one core database, and even with small per-instance HikariCP pools the fleet total crept toward the limit. The team put PgBouncer in transaction-pooling mode in front with a pool of forty real connections, while keeping small local HikariCP pools in each service for connection reuse and validation.

Remember one sentence: PgBouncer multiplexes thousands of client connections onto a few dozen real database connections at one central choke point, which an application-side pool simply cannot do across the whole fleet.

---

Share
Use ← → arrow keys to navigate