PgBouncer: Connection Pooling for Postgres on Kubernetes

Quick answer
Postgres spawns one OS process per client connection — expensive, and capped by max_connections. A fleet of application pods, or bursty serverless workloads, exhausts that cap fast. PgBouncer sits in front of Postgres and multiplexes many client connections onto a small pool of real ones. Here's how it works, the three pooling modes, and what actually breaks in transaction mode.
- The Three Pooling Modes
- What Breaks in Transaction Mode
- Deploying PgBouncer on Kubernetes
- Managed Alternatives
7 min read · Cloud Engineering
Postgres handles each client connection with a dedicated OS process. That's simple and robust, but it's not free — each connection costs roughly 5-10MB of RAM even when idle, and the practical ceiling is max_connections, typically a few hundred to a couple thousand on a reasonably sized instance. A fleet of application pods each holding a handful of open connections adds up fast, and bursty workloads — a spike of Lambda invocations, a batch job fanning out — can open and close connections faster than Postgres wants to handle.
PgBouncer sits between clients and Postgres as a lightweight proxy. Clients connect to PgBouncer; PgBouncer multiplexes them onto a much smaller pool of actual server connections. The client never talks to Postgres directly.
The Three Pooling Modes
This is the decision that matters most, and it's a trade-off between safety and how much pooling benefit you actually get.
session — a server connection is assigned to a client for the client's entire session and released only on disconnect. Every Postgres feature works exactly as if there were no pooler in front of it. The trade-off: you get almost no multiplexing benefit, since an idle client still holds a server connection open.
transaction — a server connection is checked out only for the duration of a single transaction, then returned to the pool the moment it commits or rolls back. This is where most of the real benefit lives, and it's the default most teams reach for.
statement — a server connection is released after each individual statement, even within a transaction. This breaks multi-statement transactions outright and is rarely the right choice; it exists mainly for specific load-balancing setups that don't need transactional guarantees at all.
1; pgbouncer.ini
2[databases]
3payments = host=payments-db.cluster-xyz.us-east-1.rds.amazonaws.com port=5432 dbname=payments
4
5[pgbouncer]
6pool_mode = transaction
7max_client_conn = 2000
8default_pool_size = 25max_client_conn is how many clients PgBouncer accepts; default_pool_size is how many real Postgres connections it actually holds open per database/user pair. The ratio between those two numbers is the entire point.
What Breaks in Transaction Mode
Transaction pooling works by handing out a different underlying server connection to the same client across different transactions. That silently breaks anything that assumes a stable session:
- Prepared statements — a statement prepared on one server connection doesn't exist on the next one the client gets handed. Some drivers and newer PgBouncer versions support protocol-level workarounds for this (PgBouncer 1.21+ added limited prepared statement support in transaction mode via
max_prepared_statements), but don't assume it works until you've verified it against your specific driver and PgBouncer version. - Session-level
SETcommands —SET search_path,SET statement_timeout, and similar apply to the connection PgBouncer happens to be holding at that moment, not to "the client's session." - Advisory locks —
pg_advisory_lockties a lock to the backend process holding it. If the client's next transaction lands on a different server connection, the lock semantics break. LISTEN/NOTIFY— requires a persistent connection to receive notifications; transaction pooling actively defeats this.- Temporary tables — created on one server connection, invisible on the next.
If your application genuinely needs any of these, put that specific workload on session mode (or bypass the pooler entirely for it) rather than fighting transaction mode.
AWS Cost & Architecture Review Checklist
The questions we ask in a paid AWS review — rightsizing, storage classes, network egress, and the usual five-figure surprises. Plain Markdown.
Free. Instant download. You'll also get the occasional deep-dive from the newsletter — unsubscribe anytime.
Deploying PgBouncer on Kubernetes
Two patterns, with a real trade-off between them.
Sidecar per pod — PgBouncer runs as a second container in every application pod:
1containers:
2 - name: app
3 image: payments-api:1.4.0
4 env:
5 - name: DATABASE_URL
6 value: "postgres://app:pass@localhost:6432/payments" # points at the sidecar
7 - name: pgbouncer
8 image: pgbouncer/pgbouncer:1.23.1
9 ports:
10 - containerPort: 6432
11 volumeMounts:
12 - name: pgbouncer-config
13 mountPath: /etc/pgbouncerSimplest to reason about — no shared failure domain, each pod's pooling is independent. The cost: with N pods each holding default_pool_size connections, you can end up provisioning more total Postgres connections than a single shared pooler would, since each sidecar has no visibility into what the others are doing.
Shared Deployment in front of Postgres — one PgBouncer Deployment (typically 2-3 replicas behind a Service) that every application pod connects to:
1apiVersion: apps/v1
2kind: Deployment
3metadata:
4 name: pgbouncer
5spec:
6 replicas: 2
7 template:
8 spec:
9 containers:
10 - name: pgbouncer
11 image: pgbouncer/pgbouncer:1.23.1
12 ports:
13 - containerPort: 6432
14 resources:
15 requests: { cpu: 100m, memory: 128Mi }
16 limits: { cpu: 500m, memory: 256Mi }
17---
18apiVersion: v1
19kind: Service
20metadata:
21 name: pgbouncer
22spec:
23 selector:
24 app: pgbouncer
25 ports:
26 - port: 6432Centralizes the pool — you tune default_pool_size once, and it actually reflects the real ceiling against Postgres. The trade-off: it's a shared dependency between every application in the cluster. If it falls over, everything using it loses database access at once, so run at least 2 replicas and monitor it like any other critical piece of infrastructure — not as an afterthought bolted onto the database.
For most multi-service clusters, the shared Deployment is the better default: connection limits are a cluster-wide resource, and managing them per-pod scales badly as the number of application pods grows.
Managed Alternatives
If you'd rather not operate PgBouncer yourself, two managed options are worth knowing:
AWS RDS Proxy — fully managed by AWS, sits in front of RDS/Aurora with no infrastructure to run. The trade-off is cost: RDS Proxy is priced per vCPU of the underlying database instance, which typically adds 10-30% to the RDS bill. Worth it if you want zero operational burden and are already deep in the AWS ecosystem.
Supavisor — Supabase's open-source pooler, written in Elixir on the BEAM VM, open-sourced in 2023 and the default pooler on Supabase since early 2024. Unlike PgBouncer, it's designed to run as a single cluster that pools connections for many databases at once (routing by tenant), and it can survive the loss of an individual node. It's runnable standalone against any Postgres, not just Supabase-hosted ones, if you want a more modern architecture than PgBouncer without the AWS-specific cost model of RDS Proxy.
Frequently Asked Questions
Do I need PgBouncer if I'm already using RDS Proxy or Supavisor?
No — they solve the same problem. Running PgBouncer in front of a database that's already behind RDS Proxy just adds a redundant hop and another thing to operate. Pick one layer of pooling, not two.
Will transaction mode break my ORM?
It depends on the ORM and driver, not just PgBouncer. Some ORMs issue session-level SET commands on every checkout (Rails' ActiveRecord and some Django configurations have historically done this), which silently misbehave under transaction pooling. Test your specific ORM's connection lifecycle against transaction mode before assuming it works — don't assume compatibility from the ORM's general popularity.
How do I size default_pool_size?
Start from Postgres's actual max_connections and divide by the number of distinct pools you're running (one per database/user pair, times however many PgBouncer replicas share that backend). A common starting point is sizing the pool so that even at 100% pool utilization across all PgBouncer replicas, you stay comfortably under Postgres's max_connections with headroom for admin connections and replication.
Does PgBouncer add meaningful latency?
The proxy hop itself adds sub-millisecond overhead in the common case — negligible next to typical query latency. The real latency risk is pool exhaustion: if every connection in the pool is checked out, new requests queue for one to free up, which shows up as latency spikes under load, not a steady overhead.
For the storage and operational patterns PgBouncer typically sits in front of, see Kubernetes StatefulSets: Running Databases in Production. For the managed-database side of this trade-off, see AWS RDS and Aurora: Managed Database Patterns.
Hitting connection limits on a production Postgres instance? Talk to us at Coding Protocols — we help platform teams design connection pooling that doesn't quietly break the features your application depends on.
Official References
- PgBouncer documentation — pool modes, configuration reference
- Amazon RDS Proxy documentation — pricing model and setup
Was this article helpful?
Be the first to rate this article
Related Topics
Found this useful? Share it.


