PgBouncer and its alternatives: revisit your PostgreSQL connection pooler
A team runs about forty services on Kubernetes against one PostgreSQL primary. During a sale, the autoscaler adds pods, each opens its own pool of ten connections, and PostgreSQL starts refusing logins with “remaining connection slots are reserved”. Someone puts PgBouncer in transaction mode in front, and the errors stop. Two days later a worker logs “prepared statement does not exist”, a report runs with another job’s statement_timeout, and a scheduler that relied on an advisory lock runs a job twice. The pooler fixed one problem and exposed three assumptions.
This post covers why PostgreSQL needs a pooler, what each pool mode breaks, the history and state of PgBouncer, PgCat, Odyssey, Supavisor and pgagroal, what PostgreSQL 18 and the 19 beta change, sizing, a decision guide and a checklist. Facts come from the projects’ repositories, release notes, licence files and docs, the PostgreSQL docs and release notes, and Supabase and AWS docs, checked on 7 October 2026. Drill results are from my own run. The earlier post on PostgreSQL asynchronous I/O covered storage; this one stays with connections.
The short version
- PostgreSQL starts one backend process per connection, and
max_connectionsdefaults to 100. A pooler lets many clients share a few server connections. - Transaction pooling saves the most and breaks session features:
SET, session advisory locks,LISTEN,WITH HOLDcursors, SQLPREPAREand long-lived temporary tables. - Protocol-level prepared statements now work in transaction mode in PgBouncer (since 1.21, default on since 1.24), Odyssey and PgCat, when the setting is on. With it off, asyncpg failed 480 of 1,000 calls through PgBouncer and 980 through Odyssey in my drill.
- Current releases: PgBouncer 1.26.0 (23 September 2026), Odyssey 1.5.2 (13 September 2026), Supavisor 2.9.13 (10 September 2026), pgagroal 2.1.0 (29 April 2026). PgCat’s last tag is from August 2024 and its last commit from February 2025.
- PostgreSQL 18 added protocol 3.2 and reports
search_path, which PgBouncer 1.26.0 now tracks. Asking for protocol 3.2 got 3.0 through PgBouncer in my drill; Odyssey 1.5.2 and PgCat closed the connection. - PgBouncer is single-threaded. In my run one process used a full core and gave under half the direct throughput on loopback; four processes on one port with
so_reuseportroughly doubled it.
Where things stand on 7 October 2026
PgBouncer
PgCat
Odyssey
Supavisor
pgagroal
Latest release
1.26.0, 23 September 2026
v1.2.0 tag, 30 August 2024; Helm chart pgcat-0.2.5, November 2024
1.5.2, 13 September 2026; 1.6.0-rc1, 5 October 2026
v2.9.13, 10 September 2026
2.1.0, 29 April 2026
Recent activity
Regular releases, several security fixes in 2026
Last commit on main 27 February 2025
Active, release candidates every few weeks
Active, frequent patch releases
Active, 2.0 line since January 2026
Licence
ISC
MIT
BSD 3-Clause
Apache 2.0
BSD 3-Clause
Language and model
C, libevent, single-threaded
Rust, Tokio, multi-threaded
C, multi-threaded workers
Elixir, a cluster of nodes
C, process model with shared memory
Pool modes
Session, transaction, statement
Session, transaction
Session, transaction
Transaction, session, native
Performance, session, transaction pipelines
Prepared statements in transaction mode
max_prepared_statements, default 200
prepared_statements_cache_size, default 0
pool_reserve_prepared_statement
Not supported on Supabase’s shared transaction pooler, per its docs
PREPARE listed as unsupported in the transaction pipeline
The timeline, from the repositories and release notes:
Date
Event
13 March 2007
PgBouncer 1.0, first public release; the copyright file names Marko Kreen and Skype Technologies
May 2018
Odyssey repository created under Yandex on GitHub
August 2019
pgagroal repository created
January 2022
PgCat repository created
January 2023
Supavisor repository created by Supabase
16 October 2023
PgBouncer 1.21.0, “The one with prepared statements”
10 January 2025
PgBouncer 1.24.0 sets max_prepared_statements to 200 by default
25 September 2025
PostgreSQL 18 released, with protocol 3.2 and search_path reporting
28 January 2026
pgagroal 2.0.0 with a new I/O layer built around io_uring and kqueue
23 September 2026
PgBouncer 1.26.0 tracks search_path and removes the old online restart (-R)
24 September 2026
PostgreSQL 19 Beta 4
Why PostgreSQL needs a pooler
The PostgreSQL docs describe a “process per user” model: the postmaster spawns a backend process for every connection. Resources are sized from max_connections, which is set at server start and typically 100, with three slots reserved for superusers by default.
Application-side pools help, but they multiply: forty services with ten pods each and a pool of ten is 4,000 possible connections. A server-side pooler accepts thousands of cheap client connections and maps them onto a small set of real backends.
In my drill, 150 pgbench clients connecting directly failed with “remaining connection slots are reserved for roles with the SUPERUSER attribute”. Through PgBouncer with a pool of 20, the same 150 ran with no failures.
Pool modes and what each one breaks
PgBouncer’s three modes are the reference:
- Session: a server connection belongs to a client until it disconnects. Safe, but idle clients still hold backends.
- Transaction: the server connection returns to the pool when a transaction ends. This is where the savings are.
- Statement: released after every statement; multi-statement transactions are not allowed.
In transaction mode, one client’s consecutive transactions can run on different backends. Anything kept in the session either disappears or leaks to the next client.
Feature
Session
Transaction (PgBouncer docs)
My drill, transaction mode
SET / RESET
Works
Never
Leaked to another client, or lost
SET LOCAL inside a transaction
Works
Works (transaction scoped)
No leak
Session advisory locks
Works
Never
Another client got the same lock and unlocked it
LISTEN
Works
Never
Nothing received; PgBouncer logged a packet “from server when not linked”
WITH HOLD cursors
Works
Never
Not tested
SQL PREPARE / DEALLOCATE
Works
Never
Not tested
Protocol-level prepared statements
Works
Works if max_prepared_statements is above 0
Failed with 0, worked with 200
Temporary tables kept across transactions
Works
Never
Not tested
The PostgreSQL docs explain why: prepared statements “only last for the duration of the current database session”, and a session-level advisory lock “is held until explicitly released or the session ends”. Both belong to a backend, not to your client.
Prepared statements, the part that changed
SQL PREPARE is a statement that poolers cannot follow safely. Protocol-level prepared statements use Parse, Bind and Execute messages, and most drivers use them: psycopg with prepare=True or after a threshold, asyncpg by default, pgbench with -M prepared.
With max_prepared_statements above 0, PgBouncer gives each unique query text an internal name such as PGBOUNCER_1 and prepares it on whichever server connection the client lands on. In my drill, the server’s pg_prepared_statements showed only those names. Clients preparing the same query share one server-side statement.
Cautions from the PgBouncer docs: after a column type change, cached plans can fail until you run RECONNECT, and plain DEALLOCATE is still unsupported (DEALLOCATE ALL and DISCARD ALL work since 1.22). The psycopg docs need PgBouncer 1.22 or newer and libpq 17 or newer.
Session state and parameters
PgBouncer restores parameters that PostgreSQL reports to the client when a client moves to another backend. Since 1.26.0 that covers everything reported by default, notably search_path on PostgreSQL 18 and later; before that, per the release notes, SET search_path in transaction mode could affect other clients. Unreported settings such as work_mem or statement_timeout are not tracked. Use SET LOCAL, or set them per role or database.
The projects, one by one
PgBouncer
PgBouncer started at Skype in 2007, written by Marko Kreen, under the ISC licence. It is a small C program on libevent and single-threaded; the docs point to so_reuseport for using more cores. Since 1.19 processes can peer so cancel requests still work, since 1.23 SIGTERM waits for clients, which allows rolling restarts, and 1.26.0 removed the old -R takeover restart.
Authentication covers md5, SCRAM-SHA-256 (since 1.11), certificates, HBA, auth_query and LDAP (since 1.25), with TLS on both sides. Observability is the admin console (SHOW POOLS, SHOW STATS); Prometheus needs a separate exporter. A database entry can list several hosts, used round robin, but there is no read and write split or sharding.
In 2026, 1.25.2 fixed four CVEs and 1.26.0 three more, including a crash an unauthenticated client could trigger with a malformed SCRAM message. 1.26.0 also tells clients at login that they are on PgBouncer (pgbouncer.version and two more parameters), which psycopg saw in my run.
PgCat
PgCat is a multi-threaded Rust pooler on Tokio. Its README lists transaction and session pooling, read load balancing across replicas, failover with health checks, Prometheus statistics, TLS and live reload, plus experimental sharding and mirroring. Instacart’s engineering blog describes its adoption. Client authentication is MD5 per the README.
The concern is maintenance: the last tag is v1.2.0 (August 2024) and the last commit on main is from 27 February 2025. There is no package in the PostgreSQL apt repository, so I built it from main. If you choose it, plan to own the build and the fixes.
Odyssey
Odyssey comes from Yandex, on GitHub since 2018 under BSD 3-Clause. Worker threads share global server pools, which the README says matters for TLS, and abandoned transactions are rolled back before a server is reused.
Authentication includes md5, SCRAM-SHA-256, certificates, PAM and LDAP. Since 1.4.1 it balances across hosts, preferring localhost, availability zone and target session attributes. Since 1.5.0 it restarts online with bindwith_reuseport and SIGUSR2. Prometheus needs a separate exporter. Prepared statements in transaction mode need pool_reserve_prepared_statement yes, and pool_pin_on_listen keeps a client on one backend after LISTEN. The 1.6.0-rc1 notes include “support protocol negotiation”, which matters below.
Supavisor
Supavisor is Supabase’s pooler, written in Elixir under Apache 2.0. It runs as a cluster: tenant configuration lives in a PostgreSQL metadata database, pools start on demand, and the README describes rolling and blue-green deployments, Prometheus metrics and a management API. It has transaction, session and “native” modes.
It is built for many tenants; read replica load balancing is still under future work in the README. Supabase’s docs say its shared pooler’s transaction mode “does not support prepared statements”, and its dedicated pooler is PgBouncer. For one database, Supavisor brings a cluster and a metadata store you may not need.
pgagroal
pgagroal began in 2019; its authors file lists Jesper Pedersen, David Fetter and Will Leinweber, and it is BSD 3-Clause. It uses processes and shared memory, with io_uring since 2.0. It has a fast “performance” pipeline without TLS or failover, a full “session” pipeline, and a “transaction” pipeline where SET, LISTEN/NOTIFY, WITH HOLD cursors and PREPARE are unsupported. It offers Prometheus metrics with a Grafana dashboard, failover support, TLS and a user vault.
Managed proxies, briefly
Amazon RDS Proxy is a managed option. Its docs describe “pinning”: for PostgreSQL, SET, prepared statements, temporary tables, cursors and LISTEN pin a client to one connection, while SET LOCAL does not. Same trade-off, managed for you.
Feature comparison
From each project’s docs and README.
Capability
PgBouncer 1.26
PgCat main
Odyssey 1.5
Supavisor 2.9
pgagroal 2.1
Uses many cores in one process
No; so_reuseport across processes
Yes
Yes
Yes, across a cluster
Process model
Read and write split or replica balancing
Host list, round robin
Yes
Host selection by attributes
Future work in README
Failover support only
Sharding
No
Experimental
Not in README
Not in README
Not in README
Metrics
SHOW commands, external exporter
Prometheus endpoint
External exporter
Prometheus /metrics
Prometheus, Grafana
What PostgreSQL 18 and 19 change for poolers
From the PostgreSQL 18 release notes:
- Wire protocol 3.2, with 256-bit cancel request keys, and new libpq settings
min_protocol_versionandmax_protocol_version. The default maximum is still 3.0 in the 18 and 19 docs, so libpq clients ask for 3.2 only if told to. search_pathis reported to the client. This is what lets PgBouncer 1.26.0 track it.
In my drill, psql 18 with max_protocol_version=latest got 3.2 directly and 3.0 through PgBouncer 1.26.0, while Odyssey 1.5.2 and PgCat closed the connection. If a driver starts asking for 3.2, test it through the pooler first.
The PostgreSQL 19 release notes (draft as of 14 September 2026) mention “protocol grease”: libpq parameters sent during the beta for compatibility testing, not in the final release. If a 19 beta client fails through a pooler, check this first. Beta 4 came out on 24 September 2026. I found nothing else in the 19 notes aimed at poolers.
Performance: claims and what I measured
I found no recent independent benchmark of these poolers on common hardware. Each project’s own figures:
- PgBouncer’s own run: the 1.21.0 changelog says that in synthetic benchmarks, prepared statement support raised throughput “anywhere from 15% to 250%”.
- Supavisor’s own run: its README shows pgbench with 100 clients and a pool of 60 at 195.96 tps through PgBouncer and 189.23 tps through Supavisor, on 2023-era versions, and a two-node load test with 1,003,200 client connections.
- PgCat’s own claim: “used in production to serve hundreds of thousands of queries per second”, without a method.
Read these as project claims. My numbers, in the drill, come from one small machine.
Operations: sizing, placement and HA
Size from the server backwards. Take max_connections, subtract reserved slots, replication, monitoring and an admin margin, and divide the rest between all pooler processes and pools. In PgBouncer a pool is a database and user pair, and default_pool_size (default 20) applies to each pair, so ten users on one database can mean 200 connections per process. max_db_connections caps a database across users. With four processes, multiply by four.
Watch the queue. Per the PgBouncer docs, a rising maxwait in SHOW POOLS means the pool is too small or the server overloaded. On an overloaded server, a bigger pool does not help.
Choose placement on purpose. A sidecar per pod keeps latency low but multiplies pools by pod count, the problem you started with. A central tier enforces one budget but needs its own HA: two or more instances behind a service, clients that retry, and a rehearsed primary failover. On Kubernetes, CloudNativePG’s Pooler resource deploys PgBouncer pods in front of the cluster.
A starting pgbouncer.ini for transaction pooling:
[databases]
appdb = host=pg-primary port=5432 dbname=appdb
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 2000 ; raise the file descriptor limit too
default_pool_size = 20 ; per database and user pair, per process
max_db_connections = 60 ; hard cap for appdb across users
reserve_pool_size = 5
max_prepared_statements = 200 ; the default since 1.24; never 0 with asyncpg
query_wait_timeout = 30 ; default 120 s; 0 queues clients forever
so_reuseport = 1 ; run several processes on one port
; peer_id and a [peers] section keep cancel requests working across processes
A decision guide
Your situation
Reasonable first choice
A few databases, standard transaction pooling
PgBouncer 1.26, with so_reuseport if one core is not enough
Heavy TLS or many cores in one process
Odyssey, after testing your drivers
Replica routing or sharding in the proxy
PgCat, if you will maintain it yourself
Many tenants behind one service
Supavisor, if you can run its cluster
Small C pooler with io_uring, Prometheus and failover
pgagroal, mainly in session mode
Managed AWS database
RDS Proxy, after checking pinning
Needs LISTEN, session locks or temp tables
A direct or session-mode connection for that component
A practical checklist
- List the session features each service uses:
SET, advisory locks,LISTEN, temp tables,WITH HOLDcursors, SQLPREPARE. - Decide the mode per service. Workers that
LISTENcan connect directly. - Turn on prepared statement support in the pooler and check driver versions.
- Replace
SETwithSET LOCALor role and database settings. - Use
pg_advisory_xact_lockinstead of session advisory locks where possible. - Write down the connection budget, including every pooler process.
- Set
query_wait_timeoutto suit your clients. - Watch
SHOW POOLSand server CPU together. - Rehearse failover and a rolling restart through the pooler.
- Read release notes on every upgrade; recent releases fixed security bugs.
Common mistakes
- Disabling prepared statements everywhere out of old habit.
- Trusting a clean pgbench run. In my drill,
pgbench -M preparedthrough PgBouncer withmax_prepared_statements = 0logged ten errors yet reported 0 failed transactions, because every client used the same name for the same query. - Sizing per pod and forgetting multiplication across pods, users and pooler processes.
- Assuming the pooler is never the bottleneck. One PgBouncer process can saturate a core.
Drill: one PostgreSQL, three poolers, four surprises
I ran this on 7 October 2026, about 6:26 to 6:37 AM IST, on a shared VM container: 8 vCPUs (Intel Xeon), 15 GB RAM with about 3.4 GB free, Debian 13, Linux 6.12. PostgreSQL 18.6, PgBouncer 1.26.0 and Odyssey 1.5.2 came from the PostgreSQL apt repository; PgCat was built from main (commit 5b03881, version 1.3.0). Clients: Python 3.13.5, psycopg 3.3.6 with libpq 18.6, asyncpg 0.32.0, pgbench 18.6. Everything ran on localhost and was stopped afterwards.
Prepared statements in transaction mode
Each pooler had two server connections. Test one: two psycopg clients each ran a different query 50 times with prepare=True, both named _pg3_0. Test two: 20 asyncpg connections, 50 calls each.
Path
psycopg, 100 calls
asyncpg, 1,000 calls
Direct to PostgreSQL
100 correct
1,000 correct
PgBouncer, max_prepared_statements = 0
50 correct, 50 “_pg3_0 already exists”
520 correct, 480 “does not exist”
PgBouncer, max_prepared_statements = 200
100 correct
1,000 correct
Odyssey, pool_reserve_prepared_statement no
0 correct, 100 “does not exist”
20 correct, 980 “does not exist”
Odyssey, pool_reserve_prepared_statement yes
100 correct
1,000 correct
PgCat, cache size 0
0 correct, 100 “does not exist”
Hung; timed out
PgCat, prepared_statements_cache_size = 500 in the pool section
100 correct
Hung; timed out
Two PgCat notes. The cache size under [general], where CONFIG.md documents it, did not enable support in my run; the pool section did, as in the example pgcat.toml. asyncpg hung with both, and PgCat logged “Unexpected code: H” (H is the protocol’s Flush message). I did not dig further.
Session state, locks and LISTEN
Clients A and B through PgBouncer with a pool of one (so they share a backend), against two direct connections:
Step
Direct
Through PgBouncer
A: SET LOCAL work_mem in a transaction; B reads work_mem
4MB
4MB
A: SET work_mem = '77MB'; B reads it
4MB
77MB, leaked
A: SET search_path = leak_schema; B reads it
Default
Default; A still saw its own value
A: SET statement_timeout = '300ms'; B runs pg_sleep(0.6)
Completed
Cancelled by statement timeout
A: pg_advisory_lock(42); B: pg_try_advisory_lock(42)
false
true
B: pg_advisory_unlock(42)
false
true, B released A’s lock
LISTEN jobs on this path, NOTIFY from a direct session
Received
Nothing in 2 seconds
With a pool of two, the opposite happened: after A’s SET work_mem = '77MB', B opened a transaction on that backend and A’s next query ran on the other one with 4MB. Lost, not leaked. The search_path row matches the 1.26.0 release note.
Protocol versions
Path
max_protocol_version=latest
Default (3.0)
Direct, PostgreSQL 18.6
3.2
Works
PgBouncer 1.26.0
Connected at 3.0
Works
Odyssey 1.5.2
“server closed the connection unexpectedly”
Works
PgCat main
Same
Works
A tiny pgbench run, not a benchmark
Scale 10, select-only (-S), 4 threads, 15 seconds, pgbench on the same machine:
Run
Result
50 clients, direct
130,551 tps, 0.38 ms average latency
50 clients, one PgBouncer, pool 20
51,249 tps, 0.98 ms; PgBouncer at 99.7% of one core in one top sample
50 clients, four PgBouncer processes with so_reuseport, pool 5 each
102,063 tps, 0.49 ms
50 clients, new connection per transaction (-C), direct
316 tps
Same through PgBouncer
476 tps
150 clients, direct
Failed: connection slots exhausted
150 clients through PgBouncer, pool 20
53,142 tps, 0 failed
With tiny loopback queries, the extra hop cost more than it saved and one process was the limit. With reconnects, the pooler helped by skipping server logins. Its main value was letting 150 clients in at all.
Honest limits: one machine, loopback, no TLS, tiny queries, single runs, no failover. I did not run Supavisor (it needs a cluster and metadata database) or pgagroal (installed, not tested in the time box). PgCat was an unreleased build.
What to unlearn and re-learn
- Unlearn “a pooler is transparent”. Re-learn it as a change in what a session means.
- Unlearn “transaction pooling means no prepared statements”. Re-learn that PgBouncer, Odyssey and PgCat now support them, if the setting is on and the driver is new enough.
- Unlearn “bigger pools are safer”. Re-learn sizing from
max_connectionsbackwards, multiplied by every pooler process. - Unlearn “pick the fastest pooler”. Re-learn to pick on maintenance, features and failure behaviour, then test your drivers through it.
Revisit your connection pooler before the next scale-up
PgBouncer is still the default for good reasons, and each alternative solves a real problem: cores, routing, tenants or a different I/O model. Learn what your services keep in the session, unlearn the idea that a pooler changes nothing, re-learn the pool modes and the new prepared statement support, practise the drill on a copy with your real drivers, and apply the results in your connection budget and runbooks. Do it before the next scale-up or PostgreSQL major upgrade, not during it.
Sources
- PostgreSQL docs: How connections are established and connection settings, max_connections
- PostgreSQL docs: PREPARE, advisory locks, LISTEN, DECLARE (WITH HOLD), SET
- PostgreSQL docs: protocol message formats (Flush)
- PostgreSQL 18 release notes and PostgreSQL 18 released, 25 September 2025
- libpq connection parameters, PostgreSQL 18 and PostgreSQL 19
- PostgreSQL 19 release notes (draft) and PostgreSQL 19 Beta 4 released, 24 September 2026
- PostgreSQL versioning data
- PgBouncer website, configuration, features and SQL feature map, usage and admin console, changelog
- PgBouncer repository, COPYRIGHT (ISC), releases, 1.26.0 release, 1.25.2 release
- Prometheus community PgBouncer exporter
- PgCat repository and README, CONFIG.md, example pgcat.toml, LICENSE, tags, commits
- Instacart engineering: Adopting PgCat
- Odyssey repository and README, releases, 1.5.2 release, 1.6.0-rc1 release, LICENSE
- Odyssey docs: pooling, rules configuration, balancing, online restart, Prometheus metrics
- Supavisor repository and README, releases, v2.9.13 release, LICENSE
- Supavisor docs, pool modes, migrating from PgBouncer
- Supabase docs: connecting to Postgres
- pgagroal repository and README, releases, 2.1.0 release, configuration, pipelines, AUTHORS, LICENSE
- Amazon RDS Proxy overview and avoiding pinning
- CloudNativePG docs: connection pooling
- Psycopg 3 docs: prepared statements and PgBouncer
- asyncpg FAQ
- pgbench documentation
Releted Posts
PostgreSQL asynchronous I/O: revisit your settings before you upgrade
A team upgrades its reporting database from PostgreSQL 16 to 18 over a weekend. On Monday the nightly scans finish a little faster, and nobody knows why, because nobody changed a setting.
Read moreRedis, Valkey, and Dragonfly clients: revisit your connection settings before the next failover
An order service runs on Valkey with Sentinel. At 2 AM the team runs a planned failover before patching the primary.
Read moreRedis, Valkey, and Dragonfly memory and upgrades: revisit before the next spike
A payments team runs its session cache with maxmemory 12gb on a 16 GB node and feels safe. On a festival sale evening a replica reconnects, a reporting job pulls a few huge hashes, and the kernel kills the process while used_memory still shows less than 12 GB.
Read more