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.
Infrastructure engineer with 10+ years building production systems on AWS, GCP,…

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
| Connections | Estimated RAM (idle) | Process Overhead | Context Switching |
|---|---|---|---|
| 50 | 250-500 MB | Low | Minimal |
| 200 | 1-2 GB | Moderate | Noticeable |
| 500 | 2.5-5 GB | High | Significant |
| 1,000 | 5-10 GB | Very high | Performance drops |
| 5,000 | 25-50 GB | Extreme | Server 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
- Install PgBouncer --
apt install pgbounceron Debian/Ubuntu orbrew install pgbounceron macOS - Configure the connection string -- point PgBouncer at your PostgreSQL server in
pgbouncer.ini - Set up authentication -- create
userlist.txtwith username/password pairs or useauth_queryfor dynamic auth - Choose a pooling mode -- transaction mode for most applications (see below)
- Point your application at PgBouncer -- change the connection port from 5432 to 6432
- Monitor the pool -- connect to PgBouncer's admin console and run
SHOW POOLSandSHOW STATS - Tune pool sizes -- start with
default_pool_size = 2x CPU coresand adjust based onSHOW POOLSoutput
# 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.
| Mode | Connection Returned | Features Supported | Efficiency | Use Case |
|---|---|---|---|---|
| Session | When client disconnects | All PostgreSQL features | Low | Legacy apps, LISTEN/NOTIFY |
| Transaction | After each transaction | Most features | High | Web apps, APIs (recommended) |
| Statement | After each statement | Very limited | Highest | Simple read-only queries |
Transaction Mode (Recommended)
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),SETcommands,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
| Pooler | Architecture | Prepared Statements | Load Balancing | Kubernetes Ready |
|---|---|---|---|---|
| PgBouncer | Single-threaded, lightweight | Not in transaction mode | No | Sidecar or standalone |
| pgpool-II | Multi-process, feature-rich | Yes | Yes (read replicas) | Complex setup |
| Supavisor | Elixir-based, multi-tenant | Yes (protocol-level) | Limited | Managed by Supabase |
| pgcat | Rust, multi-threaded | Yes | Yes (sharding support) | Yes |
| RDS Proxy | AWS managed | Yes | Limited | N/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.
- 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.
- 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.
- 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
| Provider | Built-in Pooling | Additional Cost | Max Connections |
|---|---|---|---|
| AWS RDS Proxy | Separate service | ~$43/mo (per proxy) | Scales with instance |
| Supabase | Supavisor (built-in) | Included | Varies by plan |
| Neon | Built-in pooler | Included | Up to 10,000 |
| Azure Database | PgBouncer built-in | Included | Configurable |
| Google Cloud SQL | AlloyDB Omni | Included | Varies |
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
- Set
default_pool_sizeto 2-4x your PostgreSQL server's CPU core count. A 4-core server runs well with 8-16 pool connections. - Set
max_client_connto the total connections your application layer needs. 500-2000 is typical for web applications. - Set
max_db_connectionsbelow your PostgreSQLmax_connectionssetting, leaving room for admin connections. - Monitor
cl_waitinginSHOW POOLS. If clients are regularly waiting, increase the pool size or optimize slow queries that hold connections. - Enable
server_reset_queryto clean up session state between clients:server_reset_query = DISCARD ALL - Set reasonable timeouts --
query_timeoutprevents 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.
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
SQLite at the Edge: When libSQL Beats Postgres
SQLite at the edge via libSQL embedded replicas and Cloudflare D1 delivers 2-5ms reads worldwide versus 20-100ms for Postgres read replicas. Real benchmarks, pricing comparisons, production failure modes, and a decision framework for when edge SQLite wins and when Postgres-with-replicas is still the right call.
15 min read
DatabasesSnowflake vs BigQuery vs Databricks vs Redshift (2026): Which Data Warehouse?
Snowflake wins on concurrency, BigQuery on serverless simplicity, Databricks on ML, Redshift on AWS depth. Real 2026 pricing, TPC-DS benchmarks, and a clear decision matrix.
16 min read
DatabasesPostgres as a Queue: When You Don't Need Kafka or RabbitMQ
Build a production-grade task queue entirely in PostgreSQL using SELECT FOR UPDATE SKIP LOCKED, LISTEN/NOTIFY, exponential backoff retries, and dead-letter handling. Covers PGMQ, Graphile Worker, River, and when a dedicated broker actually earns its keep.
14 min read
Enjoyed this article?
Get more like this in your inbox. No spam, unsubscribe anytime.