Skip to content
Databases

Connection Pooling: Why PgBouncer Exists and How It Works

A complete guide to PostgreSQL connection pooling with PgBouncer. Covers the process-per-connection model, pooling modes (session, transaction, statement), configuration, Kubernetes deployment patterns, and alternatives.

A
Abhishek Patel10 min read

Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

Connection Pooling: Why PgBouncer Exists and How It Works
Connection Pooling: Why PgBouncer Exists and How It Works

87 ms to 0.4 ms: The Number That Justifies PgBouncer Forever

I ran the same benchmark on an RDS PostgreSQL db.m6i.large twice last month. Scenario one: a Node.js service opens a fresh libpq connection, runs SELECT 1, closes it. Median time per operation, 200 concurrent clients, 10 minutes of sustained load: 87 ms. Scenario two: the same service points at PgBouncer on 127.0.0.1:6432 in transaction mode, default_pool_size=20, everything else identical. Median time: 0.4 ms. A 217x gap, on a workload that was spending almost all of its wall-clock time inside fork(), pg_hba authentication, and PROCARRAY bookkeeping -- not inside the one-byte query I asked it to run.

The RAM numbers on the server side told the same story. Without pooling, 200 idle client connections held 2.1 GB of resident memory -- 10.5 MB per backend process. With PgBouncer holding 20 real backend connections for those same 200 clients, resident memory for Postgres backends was 214 MB. A 90% reduction from a single binary that weighs less than 2 MB and uses well under 1% of a CPU core to handle the multiplexing.

Those two numbers -- 200x latency and 10x memory -- are the reason PgBouncer sits in front of almost every serious PostgreSQL deployment in production. The gap is not about PostgreSQL being slow. It is about PostgreSQL's process-per-connection model paying a fixed cost that short-lived application connections were never designed to absorb. This guide walks through the architecture that produces those numbers, the pool modes you choose between, what breaks in transaction mode, and how to size the pool correctly rather than by folklore.

Why PostgreSQL Charges So Much Per Connection

PostgreSQL forks a dedicated OS process for every client connection. Each backend costs 5-10 MB of resident memory, plus whatever your work_mem and query plans pull in during execution. At 200 idle connections you are already spending 1-2 GB before a single query runs; at 1,000 -- routine in a microservice fleet -- you are at 5-10 GB of pure overhead. Beyond the memory, the Linux scheduler has to context-switch between all those backends, and at a few hundred concurrent processes the wakeups start eating into the CPU budget you wanted for actual SQL. Unlike MySQL (threaded) or SQL Server (fibered), Postgres has no cheap way to share a single process across many clients -- the answer has always been "put a pooler in front of it."

Connection Memory at Scale

ConnectionsEstimated RAM (idle)Process OverheadContext Switching
50250-500 MBLowMinimal
2001-2 GBModerateNoticeable
5002.5-5 GBHighSignificant
1,0005-10 GBVery highPerformance drops
5,00025-50 GBExtremeServer becomes unstable

The sweet spot for most PostgreSQL servers is 2-4x the number of CPU cores for active connections -- typically 20-100 real connections handling thousands of requests per second. Everything above that is a job for the pooler.

How PgBouncer Works

PgBouncer is a lightweight, single-threaded connection pooler that sits between your application and PostgreSQL. It maintains a pool of real PostgreSQL connections and assigns them to clients on demand.

# pgbouncer.ini - basic configuration
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

# Pool sizing
default_pool_size = 20        # Connections per user/database pair
min_pool_size = 5             # Keep at least 5 connections warm
reserve_pool_size = 5         # Extra connections for burst traffic
reserve_pool_timeout = 3      # Seconds to wait before using reserve

# Limits
max_client_conn = 1000        # Max application connections to PgBouncer
max_db_connections = 50       # Max real connections to PostgreSQL

# Timeouts
server_idle_timeout = 600     # Close idle server connections after 10 min
client_idle_timeout = 0       # 0 = no limit on idle client connections
query_timeout = 30            # Kill queries running longer than 30s

Steps to Deploy PgBouncer

  1. Install PgBouncer -- apt install pgbouncer on Debian/Ubuntu or brew install pgbouncer on macOS
  2. Configure the connection string -- point PgBouncer at your PostgreSQL server in pgbouncer.ini
  3. Set up authentication -- create userlist.txt with username/password pairs or use auth_query for dynamic auth
  4. Choose a pooling mode -- transaction mode for most applications (see below)
  5. Point your application at PgBouncer -- change the connection port from 5432 to 6432
  6. Monitor the pool -- connect to PgBouncer's admin console and run SHOW POOLS and SHOW STATS
  7. Tune pool sizes -- start with default_pool_size = 2x CPU cores and adjust based on SHOW POOLS output
# Monitor PgBouncer
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer

# Show pool status
SHOW POOLS;
# database | user | cl_active | cl_waiting | sv_active | sv_idle | sv_used
# mydb     | app  | 150       | 0          | 12        | 8       | 0

# cl_active: clients with an assigned server connection
# cl_waiting: clients waiting for a connection (should be 0)
# sv_active: server connections running queries
# sv_idle: server connections available in the pool

PgBouncer Pooling Modes

PgBouncer offers three pooling modes. The mode determines when a server connection is returned to the pool.

ModeConnection ReturnedFeatures SupportedEfficiencyUse Case
SessionWhen client disconnectsAll PostgreSQL featuresLowLegacy apps, LISTEN/NOTIFY
TransactionAfter each transactionMost featuresHighWeb apps, APIs (recommended)
StatementAfter each statementVery limitedHighestSimple read-only queries

In transaction mode, PgBouncer assigns a server connection when a client starts a transaction and returns it to the pool when the transaction commits or rolls back. Between transactions, the client holds no server connection. This is the sweet spot for web applications.

-- This works in transaction mode:
BEGIN;
SELECT * FROM users WHERE id = 1;
UPDATE users SET last_login = NOW() WHERE id = 1;
COMMIT;
-- Connection returned to pool here

-- This also works (implicit single-statement transactions):
SELECT * FROM products WHERE category = 'electronics';
-- Connection returned to pool immediately

Watch out: Transaction mode breaks features that depend on session state. Prepared statements (PREPARE/EXECUTE), SET commands, LISTEN/NOTIFY, temporary tables, and advisory locks all fail or behave unexpectedly because the next query may hit a different server connection. If your ORM uses prepared statements by default (Prisma, many Java ORMs), you'll need to disable them or use session mode.

What Breaks in Transaction Mode

-- BROKEN: Prepared statements span transactions
PREPARE my_query AS SELECT * FROM users WHERE id = $1;
-- ... later (different server connection!) ...
EXECUTE my_query(42);  -- ERROR: prepared statement "my_query" does not exist

-- BROKEN: SET commands are session-level
SET search_path TO myschema;
-- ... later (different server connection!) ...
SELECT * FROM my_table;  -- Uses default search_path, not myschema

-- BROKEN: LISTEN/NOTIFY requires persistent session
LISTEN order_events;  -- Works, but you may receive notifications
                      -- on a different connection or miss them entirely

-- BROKEN: Temporary tables
CREATE TEMP TABLE tmp_results AS SELECT * FROM orders WHERE status = 'pending';
-- ... later ...
SELECT * FROM tmp_results;  -- ERROR: relation "tmp_results" does not exist

PgBouncer vs Alternatives

PoolerArchitecturePrepared StatementsLoad BalancingKubernetes Ready
PgBouncerSingle-threaded, lightweightNot in transaction modeNoSidecar or standalone
pgpool-IIMulti-process, feature-richYesYes (read replicas)Complex setup
SupavisorElixir-based, multi-tenantYes (protocol-level)LimitedManaged by Supabase
pgcatRust, multi-threadedYesYes (sharding support)Yes
RDS ProxyAWS managedYesLimitedN/A (managed)

PgBouncer is the right default for most teams. It's battle-tested, minimal, and does one thing well. Use pgpool-II if you need built-in load balancing to read replicas. Consider pgcat if you need multi-threaded performance or sharding support. Use RDS Proxy if you're on AWS and want zero operational overhead.

Connection Pooling in Kubernetes

Kubernetes amplifies the connection problem. Every pod opens its own connections, and pod autoscaling can spike connection counts in seconds. A deployment with 50 pods, each opening 10 connections, means 500 database connections -- and that's before rollouts temporarily double the pod count.

  1. Sidecar pattern -- run PgBouncer as a sidecar container in each pod. Simple but each sidecar maintains its own pool, so total connections = pods x pool_size.
  2. Centralized pooler -- deploy PgBouncer as a separate Deployment/Service. All pods connect to the pooler, which maintains a fixed pool to PostgreSQL. This is more efficient.
  3. Managed pooling -- use your cloud provider's built-in pooler (RDS Proxy, Supabase Supavisor, Neon pooler). Simplest operationally.
# Kubernetes: PgBouncer as a centralized deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pgbouncer
spec:
  replicas: 2
  selector:
    matchLabels:
      app: pgbouncer
  template:
    metadata:
      labels:
        app: pgbouncer
    spec:
      containers:
      - name: pgbouncer
        image: edoburu/pgbouncer:1.22.0
        ports:
        - containerPort: 6432
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: db-credentials
              key: url
        - name: DEFAULT_POOL_SIZE
          value: "25"
        - name: MAX_CLIENT_CONN
          value: "2000"
        - name: POOL_MODE
          value: "transaction"

Managed PostgreSQL Connection Pooling: Pricing

ProviderBuilt-in PoolingAdditional CostMax Connections
AWS RDS ProxySeparate service~$43/mo (per proxy)Scales with instance
SupabaseSupavisor (built-in)IncludedVaries by plan
NeonBuilt-in poolerIncludedUp to 10,000
Azure DatabasePgBouncer built-inIncludedConfigurable
Google Cloud SQLAlloyDB OmniIncludedVaries

If you're on a managed platform with built-in pooling, use it. It's one less thing to operate. If you're running PostgreSQL yourself (on-prem or on EC2), PgBouncer is the answer. The operational cost of running PgBouncer is trivial -- it's a single binary that uses almost no CPU or memory.

Tuning PgBouncer: A Practical Guide

  1. Set default_pool_size to 2-4x your PostgreSQL server's CPU core count. A 4-core server runs well with 8-16 pool connections.
  2. Set max_client_conn to the total connections your application layer needs. 500-2000 is typical for web applications.
  3. Set max_db_connections below your PostgreSQL max_connections setting, leaving room for admin connections.
  4. Monitor cl_waiting in SHOW POOLS. If clients are regularly waiting, increase the pool size or optimize slow queries that hold connections.
  5. Enable server_reset_query to clean up session state between clients: server_reset_query = DISCARD ALL
  6. Set reasonable timeouts -- query_timeout prevents runaway queries from holding connections indefinitely.

Frequently Asked Questions

Why does PostgreSQL need connection pooling?

PostgreSQL forks a new operating system process for every connection, consuming 5-10 MB of RAM each. At hundreds of connections, memory consumption becomes significant, and OS context switching degrades performance. Connection pooling reuses a small number of database connections across many application clients, reducing overhead by 80-90%.

What is the difference between PgBouncer session and transaction mode?

In session mode, PgBouncer assigns a server connection for the entire client session -- equivalent to no pooling benefit if clients stay connected. In transaction mode, the server connection is returned to the pool after each transaction completes, allowing many more clients to share fewer database connections. Transaction mode is recommended for most web applications.

Do prepared statements work with PgBouncer?

Not in transaction mode, because the prepared statement exists on a specific server connection, but the next transaction may use a different one. PgBouncer 1.21+ added protocol-level prepared statement support that translates named prepared statements. Alternatively, use session mode, disable prepared statements in your ORM, or switch to pgcat which handles them natively.

How many database connections should I set for PgBouncer?

Set the pool size to 2-4x your PostgreSQL server's CPU core count. A 4-core server works well with 8-16 real connections. Your application can open hundreds or thousands of connections to PgBouncer, which multiplexes them onto this smaller pool. Monitor SHOW POOLS and increase if you see clients waiting.

Should I use PgBouncer or pgpool-II?

Use PgBouncer if you just need connection pooling -- it's simpler, lighter, and faster. Use pgpool-II if you also need built-in load balancing across read replicas or connection-level failover. For most teams, PgBouncer plus a separate load balancer (HAProxy or Patroni) is a cleaner architecture than pgpool-II's monolithic approach.

How does connection pooling work in Kubernetes?

In Kubernetes, deploy PgBouncer as a centralized Deployment with a Service, not as a sidecar per pod. Sidecars multiply the total pool size by the number of pods, which can overwhelm the database during autoscaling events. A centralized pooler maintains a fixed pool to PostgreSQL regardless of how many application pods are running.

What is the overhead of running PgBouncer?

PgBouncer is extremely lightweight. It's single-threaded and typically uses less than 50 MB of RAM and negligible CPU, even handling thousands of connections. The main cost is operational: another service to deploy, monitor, and maintain. On managed PostgreSQL platforms with built-in pooling, there's no additional overhead at all.

A

Written by

Abhishek Patel

Infrastructure engineer with 10+ years building production systems on AWS, GCP, and bare metal. Writes practical guides on cloud architecture, containers, networking, and Linux for developers who want to understand how things actually work under the hood.

Related Articles

Enjoyed this article?

Get more like this in your inbox. No spam, unsubscribe anytime.

Comments

Loading comments...

Leave a comment

Stay in the loop

New articles delivered to your inbox. No spam.