Postgres Connection Pooling with PgBouncer vs Pgpool-II
PgBouncer handles pooling lean while Pgpool-II tackles five jobs and does pooling worst.

Postgres opens a new operating system process for every single client connection. Not a thread, a full process. That one design decision, made decades ago, is the reason connection pooling exists as its own category of software. PgBouncer and Pgpool-II both sit between your app and Postgres to fix it, but they come at it from opposite directions. Pick the wrong one, and you either tank performance or miss features you didn't know you needed.
Each backend process Postgres spins up costs several megabytes of memory before it runs a single query. Multiply that by a few hundred concurrent clients, and you're burning gigabytes of RAM just to hold connections open. Add the cost of the OS switching context between all those processes, and throughput drops further. Cloud users feel this twice, since they're paying for that idle RAM by the hour.
This isn't a someday problem. It's a keep-the-lights-on problem. Treat pooling as an optimization you'll "get to later," and a connection storm will take production down at 2 AM to make the decision for you.
One thing poolers do that's easy to miss: queuing. When client demand outpaces what Postgres can handle, a pooler holds the extra clients in line instead of Postgres throwing an error back at them. A wait instead of a failure. That's often the whole difference between a slow page load and a support ticket.
The fix, broadly, is the same in both tools: keep a small number of real backend connections open to Postgres, and let many client connections share them. The question is how each tool does that sharing, and what else it decides to take on while it's at it.
Here's the position worth stating up front: for most teams, Pgpool-II is the wrong default. Not a bad tool, just the wrong first reach. PgBouncer does one job and does it lean. Pgpool-II does five jobs and pooling is the one it does worst.
PgBouncer is a single-threaded program written in C, built on libevent, running an event loop instead of spawning new processes for new work. Because it doesn't buffer full packets, it holds thousands of client connections open while using only a few megabytes of memory, total. No threads means no locking overhead either. The simplicity is the entire design, not a limitation of it.
PgBouncer also lets you set limits per pool, where a pool is a database-and-user pair. Those pairs can point at entirely different Postgres hosts, which gives fine control over who talks to what and how many connections they get.
Pgpool-II takes the opposite bet. Instead of one job done well, it aims to be a full middleware layer, with pooling as just one function among several. Rather than an event loop, it forks child processes (32 by default), and each one handles its own share of the work. Its memory footprint scales with how many child processes run, unlike PgBouncer's flat, tiny footprint. In exchange for that overhead, Pgpool-II can load-balance queries across read replicas, manage automatic failover, handle replication, cache queries, and even split a single query to run in parallel across multiple servers.
Here's the catch buried in that architecture: a client only reuses a pooled backend connection in Pgpool-II if it happens to land on the same child process that served the same database-and-user pair before. PgBouncer shares one pool across every client hitting that pair, no matter which part of the event loop handles them. That's the structural difference that explains most of what follows. It's not that one tool is smarter. Pooling efficiency is the only priority for one of them, and a side feature for the other.
PgBouncer's three pooling modes and why transaction mode is the one that matters
PgBouncer gives you three ways to hand out its pooled connections. Picking the right one is one of the biggest decisions you'll make setting it up. And most teams should land in the same place: transaction mode, by default, with session mode as the exception carved out for clients that truly need it.
Session mode hands a client one backend connection for as long as that client stays connected. It's the safest option. Session state sticks around much like it would with a direct connection. The tradeoff: it doesn't really pool anything at scale. Each client occupies a backend the whole time it's connected, close to a 1:1 ratio between clients and backends. Treat session mode as a fallback for clients that need session-scoped features, not a starting point for your whole fleet.
Transaction mode assigns a backend only for the length of a single BEGIN…COMMIT block, then returns it to the pool the moment the transaction ends. A client sitting idle between transactions holds no backend at all. That's how you go from needing one backend per client to needing a fraction of that. It's the reason PgBouncer exists. For almost any OLTP application, it's the mode to build around, not a fallback to consider later.
Statement mode goes further and returns the connection after every single statement, even mid-transaction. It gives the most aggressive multiplier of the three, but it also breaks multi-statement transactions outright. It's a specialty tool for workloads with no transactional needs at all, not a default anyone should reach for casually.
So the ranking, plainly: transaction mode first, in almost every case. Session mode only when a specific client can't survive without full session state. Statement mode stays on the shelf unless you know exactly why you need it.
PgBouncer also caps connections at a granular level: per pool, per database, per user, per client. Pgpool-II's child-process design doesn't offer that as cleanly, since its limits come from how many child processes you've forked, not from a flexible pool config.
What transaction mode breaks, and what recent PgBouncer versions have fixed
Transaction mode assumes each transaction is a clean, stateless unit. Anything a client sets up before or between transactions, that isn't itself part of a transaction, gets lost the moment the backend goes back into the pool. None of this throws a loud error. It just quietly misbehaves, which is worse than an obvious failure, because nobody gets paged for "quietly."
Here's what commonly breaks:
- SET commands that outlive a transaction. Things like
SET statement_timeoutorSET search_pathapply to the one backend assigned at the time. The next transaction might land on a completely different backend, and the setting is gone. - LISTEN/NOTIFY. A channel subscription lives on a specific backend. Once the transaction ends and that backend goes back to the pool, the client no longer owns it.
- Session-level advisory locks. This is the dangerous one. A client calls
pg_advisory_lockon one backend, then calls the matching unlock on a different backend later. The lock never actually releases. It sits there, leaked, until someone notices something's stuck. - Persistent temporary tables, since they're scoped to a session, not a transaction.
- WITH HOLD cursors meant to survive past the transaction that opened them.
- Text-level
PREPARE foo AS ...statements. PgBouncer can't see inside these to manage them.
That last point had a real fix, worth walking through because it shows the tool maturing in real time. Before version 1.21, protocol-level prepared statements (the kind libpq issues through PQprepare and the extended query protocol, as opposed to text-level SQL PREPARE) didn't work right in transaction mode. Version 1.21 added support for them. Version 1.22.0, released January 2024, added DISCARD ALL and DEALLOCATE ALL support to clean up state properly between reuses. As of version 1.24, released January 2025, that prepared statement support is on by default.
That fix is narrow, though. It only covers protocol-level prepared statements. Text-level PREPARE SQL, SET commands, LISTEN, and advisory locks remain broken in transaction mode, across every version. Anyone telling you transaction mode is "fixed now" is overstating it.
The practical workaround most teams land on: run two pools side by side. A transaction-mode pool for standard OLTP traffic, and a session-mode pool for the handful of clients that need session-scoped behavior. BI tools and internal admin dashboards are the usual suspects, since they lean on SET statement_timeout, temp tables, or LISTEN for live updates. Give those tools a session-mode pool, or a direct connection. Don't force them into transaction mode and hope.
One more addition worth flagging: version 1.25.0 introduced transaction_timeout at the pooler level. It caps how long any single transaction can run, closing a long-standing gap where a client opens a transaction, forgets to commit, and quietly holds a backend hostage for the rest of the afternoon.
Pgpool-II's actual strengths: where its complexity earns its place
None of this makes Pgpool-II a worse PgBouncer. It's not entered in the same race. Its value sits in the features PgBouncer chose, on purpose, not to build.
Read/write splitting and load balancing is the headline feature. SELECT queries spread across replica servers, while writes go to the primary (streaming replication setups) or to every server (native replication setups). More replicas, more read throughput. The catch: this only helps read-heavy traffic. Write-heavy systems see little benefit, and update performance can actually get worse as replicas increase, since more copies mean more replication overhead per write.
Automated failover is the second big one. Pgpool-II can detect when the primary goes down and redirect traffic to a standby with minimal downtime and little human intervention needed to flip the switch.
Watchdog, its high-availability coordination layer, lets multiple Pgpool-II instances work together so there's no single point of failure. It uses a quorum algorithm to prevent split-brain situations, which matters once you're running a multi-node cluster and can't afford two nodes disagreeing about who's in charge.
There's also parallel query execution, letting a single query split across multiple Postgres servers, a niche but useful feature for certain analytical workloads. Pgpool-II queues excess connections too, same as PgBouncer, though at this point that's table stakes for any serious pooler.
The practical read: Pgpool-II earns its complexity when you need failover, high availability, and read scaling handled in one layer. It is not the tool to reach for if pooling efficiency alone is the goal. Using it that way is the single most common misapplication of the tool, and it's exactly what the next section's numbers show.
How the two tools compare on raw pooling performance
Numbers make the architectural difference concrete. On a general-purpose 2-vCore Azure Database for PostgreSQL instance, adding PgBouncer produced a 4x improvement in throughput and cut connection latency by 40 percent, a controlled test isolating what the pooler itself contributes.
At higher concurrency, the gains hold. At 1,000 concurrent clients, benchmarks showed a 30 percent increase in transactions per second with PgBouncer in place, and average latency dropped from 5.3 seconds down to 4.0 seconds.
Head-to-head testing from ScaleGrid found PgBouncer faster than Pgpool-II in most scenarios tested, including ones set up to favor Pgpool-II. The reason traces back to architecture. Pgpool-II forks child processes, so running it on the same machine as Postgres effectively doubles the number of processes competing for CPU and memory. It needs its own dedicated server to perform well. Run it collocated with Postgres using default settings, and it can make things slower than running no pooler at all.
Sit with that for a second: a pooler that makes things worse. Not a rare misconfiguration story, either. It's one of the most common ways teams try to deploy Pgpool-II, because putting it on the same box as Postgres feels like the obvious, cheap choice. It isn't.
The mechanism behind the benchmark is the same one from the first section. Pgpool-II's connection reuse depends on a client landing on the same child process that served its database-and-user pair before, which caps how efficiently it reuses backends. PgBouncer shares one pool across every client for that pair, no process-matching required.
One qualification worth making, because a benchmark should never be the last word. These numbers measure pooling in isolation, on a single server. They don't capture what happens when Pgpool-II's load balancing spreads read traffic across several replicas. In a read-heavy, multi-server setup, that feature can close the gap and then some. A one-server-versus-one-server test wasn't built to show that advantage, so treat these numbers as what they measure, pooling alone, not a final verdict on which tool "wins."
Choosing between them based on what your stack actually needs beyond pooling
The real question isn't "which tool pools connections better." PgBouncer wins that one, narrowly but consistently, in most cases tested. The real question is what else has to happen at that layer besides pooling. Answer that first, and the tool picks itself. Most teams asking "PgBouncer or Pgpool-II" are actually asking "do I need failover and read replicas handled at this layer, or not?" Answer that, and the pooling question disappears.
Reach for PgBouncer when:
- Pure connection pooling is the goal, and failover or high availability is already handled elsewhere, like Patroni or a cloud provider's managed failover.
- You're running a single primary Postgres instance, or you already manage replication separately.
- Memory footprint and operational simplicity matter. PgBouncer is easy to run and easy to reconfigure without a restart.
- Your app is OLTP, and transaction mode fits (keeping the session-state limits from earlier in mind).
- You need connection limits set per user or per database with real precision.
- You want to run the pooler on the same box as Postgres. PgBouncer's tiny footprint makes that practical, while Pgpool-II's does not.
Reach for Pgpool-II when:
- You want load balancing across read replicas handled at the middleware layer, instead of writing that logic into your app.
- You want automated failover without standing up a separate HA tool just for that.
- You're building a multi-node Postgres cluster and want one layer coordinating the whole thing.
- You have the operational room to run Pgpool-II on its own dedicated server, since collocating it with Postgres works against it.
There's also a hybrid pattern worth knowing: run PgBouncer in front of Pgpool-II. PgBouncer handles connection multiplexing at the edge, and Pgpool-II sits behind it managing HA and read distribution. It's a legitimate production setup for teams that need both halves, not a compromise.
Worth a note on managed Postgres too. Providers like RDS, Cloud SQL, Neon, and Supabase increasingly bundle PgBouncer-compatible pooling, often PgBouncer itself, as a built-in feature. If you're on one of these platforms, the self-managed decision might already be made for you. Check what pooling mode and limits the managed service actually gives you before stacking another pooler on top of it.
And for internal tooling specifically: dashboards and admin panels that run ad-hoc queries, use SET to adjust query behavior, or rely on LISTEN for live updates usually need a session-mode pool or a direct connection. Build that into the pool design up front. Don't assume transaction mode works everywhere just because it works for most of your traffic.
Deploying PgBouncer in practice: the configuration decisions that actually matter
Once PgBouncer is the choice, a handful of settings decide whether it delivers or quietly underperforms.
pool_mode is the most consequential one. Set it per database or per user if different clients have different session needs, rather than forcing one global mode on everything. This is where the transaction-mode-plus-session-mode split from earlier actually gets built.
A few other parameters worth sizing deliberately, not leaving at their defaults:
- max_client_conn: the total number of client connections PgBouncer will accept. Set this well above your expected peak. The whole point of multiplexing is letting far more clients connect than you have real backends.
- default_pool_size: the number of actual Postgres backend connections in a given pool. This is the number that matters against Postgres's own
max_connectionslimit. Get this wrong and you either waste backend capacity or starve the pool during peak load. - reserve_pool_size: a small buffer of extra backends available during traffic spikes, a safety valve that keeps a burst of traffic from turning into a queue backup.
- transaction_timeout (available from 1.25.0): set this to catch clients that open a transaction and never commit, before that idle transaction holds a backend hostage.
None of these settings are exotic. They're the kind of thing that takes an afternoon to configure properly, and that afternoon is a lot cheaper than the production incident it prevents.


