Everyone into the Pool
PostgreSQL’s connection model is the one it has always had: one client connection, one operating system process. Each backend is a fork() of the postmaster, with its own catalog caches, its own memory contexts, and its own opinions about work_mem. This is simple and it is expensive. Connection setup costs a fork, an authentication exchange and (these days) a TLS handshake. Every connection that exists costs memory whether it is doing anything or not. And every connection that is doing something wants a CPU core, of which the database server has far fewer than the application tier has threads.
A connection pooler sits between the application and the database and lies to both of them. The application believes it has 5,000 connections; the database sees 40. The trick that makes this work is transaction pooling: a client gets a real server connection only for the duration of a transaction, and hands it back at COMMIT. (Session pooling, where the client keeps the server connection until it disconnects, saves you the connection setup cost and not much else. Statement pooling hands the connection back after every statement, which forbids multi-statement transactions and is as popular as that sounds.)
The trouble is that PostgreSQL sessions have state. SET, prepared statements, advisory locks, LISTEN, temporary tables, WITH HOLD cursors: all of these belong to the backend, and in transaction pooling the backend you get next time is not the one you had last time. Every pooler in this article is a different answer to the question “what do we do about session state?”, and the answers range from “nothing; read the documentation” to “we parse your SQL.”
Here are five of them, in order of appearance, since the history explains most of the design decisions. For each: where it came from, how it runs, how it pools, where it will hurt you, and what else it does.
Pgpool-II: everything, and also pooling
Pgpool-II is the oldest of the lot. It began as a personal project of Tatsuo Ishii, first public in 2003, and at that point it was a connection pooler and nothing else (hence the name). That did not last. Version 1.0 in 2004 added statement-based replication, 2.0 later that year added load balancing, and automated failover arrived in 2005. In 2006 it became Pgpool-II, dropped the two-server limit, and moved from one person to a development group. The current release is 4.7.3 (September 29, 2026), a security release fixing seven CVEs, six of them in the watchdog and its heartbeat.
The process model is PostgreSQL’s own: a parent that preforks children, one client per child. num_init_children (default 32) is both the number of processes and the limit on concurrent clients. Client number 33 is not rejected; it waits in the kernel’s listen queue until someone hangs up. Since 4.4, process_management_mode = dynamic forks children on demand, up to the same ceiling.
Each child keeps a private cache of up to max_pool (default 4) backend connections, matched on user, database and startup parameters. The caches are not shared between children, so a client that lands on a child with no matching connection opens a new one, even if an idle one is sitting in the process next door. The worst case is num_init_children * max_pool backend connections, and the documentation tells you to size max_connections for that, doubled if you want query cancellation to work when the pool is busy.
More important: this is session pooling, and only session pooling. A client holds its child and its backend connection until it disconnects. Pgpool-II saves you the connection setup cost. It does not put 5,000 clients onto 40 backends. If you need transaction pooling, Pgpool-II is not a candidate.
The quirks come from its real job, which is routing. Pgpool-II parses every statement (4.7 imports the PostgreSQL 18 parser) to decide whether it can go to a standby, and a parser sitting outside the database cannot see inside functions. It treats volatile functions as writes, and anything subtler has to be listed by hand in write_function_list. Multi-statement queries always go to the primary. set_config() is sent only to the primary while SET goes to every node in the session, so the two are not interchangeable. And pg_terminate_backend() can trigger a failover, because a terminated backend and a dead postmaster send the same message; 4.3 added failover_on_backend_shutdown to turn that off.
The additional functionality is the product: read/write splitting with replication-delay checks, automatic failover, online recovery of failed nodes, an in-memory query cache (in shared memory or memcached), and the watchdog, which runs a quorum of three or more Pgpool-II nodes and moves a virtual IP between them. There are also modes in which Pgpool-II does the replication itself by sending writes to every node. Slony-I mode was retired in 4.7.0; the developers proposed dropping it on the mailing list and heard no objections.
PgBouncer: one thread, one job
PgBouncer 1.0 was announced on March 13, 2007 by Marko Kreen of Skype, as a “lightweight connection pooler” with special features for PL/Proxy 2, Skype’s partitioning layer. Skype is gone. PgBouncer is the default answer to “which pooler?”, and has been for most of the time since. The current release is 1.26.0 (September 23, 2026).
It is one process with one thread, running an event loop on libevent. There is no fork per client and very little state per connection (the project quotes 2 kB), which is why a single PgBouncer can hold tens of thousands of mostly idle clients. It is also why one CPU core is the ceiling, and TLS and SCRAM both spend that core freely. The sanctioned workaround is so_reuseport: run several PgBouncer processes on the same port and let the kernel distribute connections, with a [peers] section (since 1.19.0) so that a cancel request arriving at the wrong process is forwarded to the right one. It works. Each process has its own pools, so do the multiplication before you set default_pool_size.
PgBouncer offers all three modes: session (the default), transaction, and statement, which exists because PL/Proxy needed it. Pools are per database and user pair, 20 server connections each by default.
In transaction mode, PgBouncer does not parse SQL. It knows what the wire protocol tells it, which is the handful of parameters PostgreSQL reports back to the client when they change. For years that meant client_encoding, DateStyle, TimeZone, standard_conforming_strings and application_name, and it meant that a SET search_path in one client quietly became the search_path of whoever got that server connection next. This is the most common way PgBouncer hurts people. PostgreSQL 18 reports search_path, and 1.26.0 tracks everything the server reports, so on 18 that particular wound is closed. Any other SET (statement_timeout, say) still leaks. Use SET LOCAL.
Prepared statements were the other long-standing complaint. Protocol-level named prepared statements have worked in transaction mode since 1.21.0 (October 2023) and have been on by default since 1.24.0, with max_prepared_statements = 200. PgBouncer renames each distinct query to PGBOUNCER_{id}, shares it across clients, and prepares it on whichever server connection needs it. SQL-level PREPARE and EXECUTE are still passed through untouched, and after a schema change you may meet cached plan must not change result type until you run RECONNECT on the admin console.
The rest of the list is “never” in transaction mode: LISTEN, WITH HOLD cursors, session-level advisory locks, temporary tables that outlive the transaction, LOAD.
PgBouncer does not split reads from writes, check replica health or fail anything over. A database entry can list several hosts, and that is the extent of it. What it adds is operational: auth_query so there is no password file to maintain, HBA files, SCRAM pass-through, LDAP (1.25.0), replication connections through the pooler (1.23.0), and an admin console whose PAUSE and RESUME are the reason so many switchovers go unnoticed by the application.
RDS Proxy: pooling as a line item
Amazon RDS Proxy was previewed at re:Invent in December 2019 for MySQL, added PostgreSQL to the preview in April 2020, and became generally available on June 30, 2020.
The process model is none of your business. AWS describes it as serverless, deployed across multiple Availability Zones, with compute independent of your instance and scaled automatically. You get an endpoint inside your VPC (it cannot be public) and a bill. The bill is per vCPU-hour of the database instance behind the proxy, or per ACU-hour on Aurora Serverless, so the price of pooling goes up with the size of the database rather than with anything the proxy does.
It pools at the transaction level, which AWS calls multiplexing. You size it as a percentage of max_connections with MaxConnectionsPercent; a client that cannot get a connection waits up to ConnectionBorrowTimeout (default 120 seconds).
Then there is pinning. When RDS Proxy sees a session do something it cannot safely multiplex, it stops trying and pins that client to its server connection until the client disconnects. For PostgreSQL the documented list is: any SET or set_config; SQL-level PREPARE, EXECUTE, DEALLOCATE and DISCARD; temporary tables, sequences and views; cursors; LISTEN; session-level advisory locks; calling nextval or setval; loading a library; and any statement over 16 KB. MySQL proxies have “session pinning filters” to exempt things you know are harmless. PostgreSQL proxies do not.
Read that list again with your ORM in mind. Rails issues several SET statements on every new connection. So does anything configured to set a time zone, a schema or a statement timeout at connect. Each of those sessions is pinned from its first second, and you are now paying per vCPU-hour for session pooling. Where PgBouncer lets session state leak and PgDog replays it, RDS Proxy gives up. Giving up is the safe choice, and it is the usual explanation when RDS Proxy “did nothing.” The CloudWatch metric DatabaseConnectionsCurrentlySessionPinned will tell you how bad it is. The one improvement of note: since November 2023, extended-protocol prepared statements no longer pin.
Other limitations, all from the documentation: no CancelRequest, so Ctrl-C in psql does not cancel the query; no streaming replication connections; protocol version 3.0 only and no direct SSL negotiation; lastval() is unreliable. On RDS for PostgreSQL the proxy attaches to the writer only, not to read replicas. Support for new major versions has lagged: PostgreSQL 13 was released in September 2020 and RDS Proxy supported it in April 2022. Check before you schedule an upgrade.
What you get for the money is failover handling and credentials. The proxy holds client connections open through a Multi-AZ failover and routes to the new primary without waiting for DNS, which is a real improvement for applications that handle reconnection badly. It authenticates clients with IAM and keeps database passwords in Secrets Manager. And somebody else is on call for it.
PgCat: best effort
PgCat starts with a first commit on February 3, 2022 by Lev Kokotov, who had run PostgreSQL at Instacart on PgBouncer and wanted the load balancing across replicas to live in the proxy instead of in the application. It was developed under PostgresML, and Instacart adopted it while it was still in beta, contributing multiple pools per instance and graceful shutdown along the way. The license is MIT.
PgCat is written in Rust on the Tokio asynchronous runtime, and it is multi-threaded. That was the headline: a PgBouncer-compatible pooler that uses all of your cores without the so_reuseport routine. (worker_threads defaults to 4. The configuration reference gives the default as 5 and, one line later, as 4.)
It offers transaction pooling (the default) and session pooling, with an admin database that answers to both pgcat and pgbouncer. Its approach to session state is to clean up after you. PgCat watches the command tags coming back from the server; if it sees a SET outside a transaction or a PREPARE, it marks the server connection and sends RESET ALL or DEALLOCATE ALL before the next client gets it. Your SET does not leak to a stranger. It also does not follow you to your next transaction. The source describes this as a “non-exhaustive list” and a “best effort,” which is honest. A prepared statement cache exists and is off by default; the documentation for prepared_statements_cache_size ends with “TODO: update documentation.”
The limitation that will bite first is authentication: clients can authenticate to PgCat with MD5 only. PgCat can use SCRAM to the server, but not from the client, and PostgreSQL 18 deprecates MD5 passwords.
Beyond pooling, PgCat splits reads from writes using the sqlparser crate (not PostgreSQL’s parser), load balances across replicas, and bans a replica that fails a health check for ban_time (60 seconds by default). Sharding, by SET SHARDING KEY, by SQL comment, or by parsing the query, is marked experimental, as is mirroring traffic to a second database.
The larger limitation is that the project has stopped moving. The last tagged release is v1.2.0, from August 2024, and the last commit to the repository was on February 27, 2025. Its author has moved on to the next entry. PgCat runs in production at Instacart and elsewhere, and if you run it today it will keep working. I would not start a new deployment on it.
PgDog: the proxy that reads your SQL
PgDog is Lev Kokotov again. The first commit is dated December 27, 2024, and the Show HN post in May 2025 called it PgCat’s “spiritual successor but with a fresh codebase and new goals,” the goal being sharding. (The name changed because he adopted a dog.) There is a company behind it now, with a $5.5M seed round announced in June 2026. The license changed too: PgDog is AGPL-3.0 where PgCat was MIT, and there is a closed-source Enterprise edition with a control plane and query monitoring. Releases are weekly, on Thursdays, and the version number is still 0.1.x.
Like PgCat, it is Rust on Tokio. workers sets the thread count (default 2; the recommendation is two per vCPU). Plugins are shared libraries loaded at startup.
It has all three pooling modes, transaction by default, with default_pool_size = 10. What makes PgDog different from everything above is that it parses SQL with PostgreSQL’s own parser, by way of pg_query, and uses the result to make transaction pooling behave like a session:
SETis parsed and recorded per client. Before a client’s transaction runs, PgDog compares the client’s parameters with the server connection’s and issues whateverSETcommands are needed.- Prepared statements go into a global cache under names like
__pgdog_1and are prepared on demand. Extended protocol is handled by default;prepared_statements = "full"also rewrites SQL-levelPREPARE, at the cost of parsing every query. - A session-level
pg_advisory_lock()pins the server connection to the client until it is unlocked. So doesCREATE TEMP TABLE. LISTENandNOTIFYwork in transaction mode. PgDog listens once on a dedicated connection and fans notifications out to clients. Delivery is at most once.
Advisory lock detection and pub/sub are off by default in a plain pooling deployment and need query_parser and pub_sub_channel_size set, so read the configuration reference before assuming you have them.
The limitations are those of a young project moving fast. A changelog that ships weekly includes entries like “2pc WAL checkpointer could cause WAL corruption” (fixed in v0.1.59). Client authentication is by password only (SCRAM, MD5 or plain); there is no auth_query (passthrough authentication covers some of the same ground), no LDAP, no HBA file. Parsing costs CPU that PgBouncer never spends. And the AGPL will mean a conversation with someone in legal before some companies can deploy it, whatever the project’s (reasonable) reading of the license.
The additional functionality is the reason it exists. PgDog load balances reads, routes writes to the primary, health-checks replicas, and follows a promotion when one happens (it does not perform the failover; that is still Patroni’s job, or your cloud provider’s). It shards, using the same hash functions as PostgreSQL’s declarative partitioning, with direct-to-shard routing, cross-shard queries with partial aggregate and sort support, two-phase commit, and online resharding built on logical replication. Cross-shard joins are still on the roadmap.
Also in the water
Two more deserve a mention. Odyssey, from Yandex, is a multi-threaded pooler in C whose worker threads share global server pools; it got to “PgBouncer, but on all the cores” before the Rust projects did. Supavisor, from Supabase, is written in Elixir and built for the multi-tenant case: a cluster of poolers in front of many unrelated databases.
They are not the only ones. Both of these, along with pgagroal, PgDoorman, ProxySQL, the poolers the other clouds bundle with their databases, and the pool already sitting in your application, are the subject of a follow-up post.
The scorecard
| Pooler | Since | Runs as | Pooling modes | SET in transaction mode |
Beyond pooling |
|---|---|---|---|---|---|
| Pgpool-II | 2003 | One process per client (C) | Session | Not applicable | Read/write split, failover, watchdog, query cache |
| PgBouncer | 2007 | One single-threaded process (C) | Session, transaction, statement | Tracked if PostgreSQL reports it; otherwise leaks | Very little, on purpose |
| RDS Proxy | 2020 | Managed service | Transaction, until pinned | Pins the session | Failover handling, IAM |
| PgCat | 2022 | Multi-threaded (Rust) | Session, transaction | Reset at check-in | Read/write split, replica banning, experimental sharding |
| PgDog | 2025 | Multi-threaded (Rust) | Session, transaction, statement | Parsed and replayed | Read/write split, sharding, two-phase commit |
Which one
If you need a pooler and nothing else, use PgBouncer. It does one thing, its failure modes have been documented for nineteen years, and most managed PostgreSQL services and Kubernetes operators already know how to run it. Upgrade to 1.26.0; it fixes three CVEs, two of which need no authentication.
If you are on RDS or Aurora, RDS Proxy earns its fee when you want failover handled and IAM authentication, and your application does not issue SET. Find out whether it does before you buy. If your sessions pin, run PgBouncer on a small instance beside the database instead.
If you want read/write splitting or sharding in the proxy, PgDog is where the work is happening. Test it against your own workload, pin a version, and read the changelog every Thursday.
If you already run PgCat, there is no emergency, but pick a successor.
I do not recommend Pgpool-II for new installations. Its pooling is the weakest part of it, and the failover and routing it wraps around that pooling are better handled by a dedicated tool such as Patroni and by an application that knows which queries are reads. If you have a Pgpool-II cluster that works, you also have someone who understands its configuration. Keep both.