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_connections defaults 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 HOLD cursors, SQL PREPARE and 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_reuseport roughly 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_version and max_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_path is 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

  1. List the session features each service uses: SET, advisory locks, LISTEN, temp tables, WITH HOLD cursors, SQL PREPARE.
  2. Decide the mode per service. Workers that LISTEN can connect directly.
  3. Turn on prepared statement support in the pooler and check driver versions.
  4. Replace SET with SET LOCAL or role and database settings.
  5. Use pg_advisory_xact_lock instead of session advisory locks where possible.
  6. Write down the connection budget, including every pooler process.
  7. Set query_wait_timeout to suit your clients.
  8. Watch SHOW POOLS and server CPU together.
  9. Rehearse failover and a rolling restart through the pooler.
  10. 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 prepared through PgBouncer with max_prepared_statements = 0 logged 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_connections backwards, 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

comments powered by Disqus

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 more

Redis, 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 more

Redis, 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