DEV Community

Libme
Libme

Posted on

PgBouncer vs Supavisor vs RDS Proxy: Which Postgres Pooler Survives Transaction Mode?

Picking a Postgres connection pooler is really picking which session features you are willing to lose. All three of these poolers give you the same win — hundreds of app connections multiplexed onto a few dozen backend connections — and all three do it by taking your connection away between transactions, which is exactly what breaks prepared statements, session SETs, LISTEN/NOTIFY and cross-transaction advisory locks. PgBouncer gives you the most control and the most operational work, Supavisor is the reasonable pick if you're already on Supabase or want a clustered pooler you don't hand-tune, and RDS Proxy is the one to choose when IAM auth and failover handling matter more than pooling efficiency.

Why does adding a pooler break my app?

The failure that sends you looking for a pooler is familiar:

FATAL: sorry, too many clients already
Enter fullscreen mode Exit fullscreen mode

Postgres allocates a backend process per connection, so max_connections is a real ceiling, and serverless functions or a scaled-out app blow through it. A pooler fixes that by keeping a small set of server connections and lending them out.

The catch is when it takes the connection back. In session pooling, a client holds one server connection until it disconnects — safe, but it barely helps, because your idle app connections still pin backends. In transaction pooling, the server connection returns to the pool at COMMIT, so 500 clients can share 20 backends. That's the mode everyone actually wants, and it's the mode that changes the semantics of your database connection.

Your driver notices first:

prepared statement "S_1" already exists
Enter fullscreen mode Exit fullscreen mode

or, with asyncpg:

prepared statement "__asyncpg_stmt_3__" does not exist
Enter fullscreen mode Exit fullscreen mode

Two different clients landed on the same server connection, and the second one's prepared statement cache is now describing a different backend than it thinks. The dangerous version of this bug is the one that throws no error at all: pg_advisory_lock() taken outside a transaction, released to the pool mid-flight, and silently held by a connection your next request never gets back. If your code assumes anything persists between transactions on the same connection, transaction pooling will break it — sometimes loudly, sometimes not.

What exactly breaks in transaction mode?

Feature Transaction mode behavior Fix
Protocol-level named prepared statements Collide across clients unless the pooler tracks them PgBouncer max_prepared_statements (1.21+), or disable in the driver
SET outside a transaction Leaks to the next client on that server connection Use SET LOCAL, or set it in the connection string / server defaults
pg_advisory_lock() (session-scoped) Held by an unrelated connection; may never release Use pg_advisory_xact_lock()
LISTEN / NOTIFY Listener silently loses its connection Dedicated direct connection, not through the pooler
Temp tables, WITH HOLD cursors Disappear after COMMIT Keep it inside one transaction, or use session mode
Long transactions Hold a backend for their whole duration Fix the transaction; the pooler can't help

The driver-side escape hatch is usually one flag. psycopg3:

import psycopg

# Through a transaction-mode pooler: don't let psycopg promote
# frequently-used queries to named prepared statements.
conn = psycopg.connect(
    "postgresql://app@pooler.internal:6432/app",
    prepare_threshold=None,
)
Enter fullscreen mode Exit fullscreen mode

asyncpg needs both the statement cache off and unique statement names disabled:

import asyncpg

conn = await asyncpg.connect(
    "postgresql://app@pooler.internal:6432/app",
    statement_cache_size=0,
)
Enter fullscreen mode Exit fullscreen mode

You pay for this: unprepared queries re-plan on every execution. For short OLTP queries the cost is small; for a complex analytical query in a hot path it isn't. That trade is the reason PgBouncer's prepared-statement support matters.

Before you compare poolers, audit your own code for session state — most "the pooler is broken" tickets are an app holding state the pooler never promised to keep.

PgBouncer: most control, most of it yours to operate

PgBouncer is the default answer because it's small, battle-tested and runs anywhere. Since 1.21 it tracks protocol-level named prepared statements in transaction mode, which removes the single most common driver breakage:

[databases]
app = host=db.internal port=5432 dbname=app

[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 20
max_prepared_statements = 200
server_reset_query = DISCARD ALL
Enter fullscreen mode Exit fullscreen mode

Watch the pool from the admin console rather than guessing:

-- psql "postgresql://pgbouncer@pooler.internal:6432/pgbouncer"
SHOW POOLS;
-- cl_waiting > 0 consistently means default_pool_size is too small
-- or transactions are running too long
SHOW STATS;
Enter fullscreen mode Exit fullscreen mode

cl_waiting is the number that tells you the truth. If it's persistently above zero, clients are queuing on the pooler and your p99 now includes pool wait time that your database metrics will never show you.

The honest drawback: PgBouncer is essentially single-threaded, so one process saturates a core under heavy traffic and you scale it by running several processes with so_reuseport or putting instances behind a load balancer — plus you now own a network hop that can page you at 3am. If you want that control and are willing to run it, PgBouncer is the pooler with the fewest surprises about what it's doing to your connections.

Supavisor: a clustered pooler you don't hand-tune

Supavisor is Supabase's open-source pooler, written in Elixir, designed to be multi-tenant and to run as a cluster rather than as a single process per host. It exposes transaction mode and session mode on separate ports, so the usual pattern is app traffic on the transaction port and migrations or LISTEN-style work on the session port. It has added named prepared statement support in transaction mode, but treat that as version-dependent and verify it with your actual driver before relying on it.

If you're on Supabase, this is not really a choice — the platform's pooler endpoints are Supavisor, and the decision collapses to "which port". Outside Supabase, it's a credible self-hosted option when you want horizontal scaling without assembling it yourself from PgBouncer processes. Supavisor is the option worth considering when you want a pooler that scales across nodes instead of one you shard by hand.

The drawback is ecosystem maturity: PgBouncer has a decade more field reports, and when something strange happens at 2am, the number of existing answers matters.

RDS Proxy: what are you actually paying for?

RDS Proxy is the managed option for RDS and Aurora, and pooling is arguably not its best feature. What you're buying is IAM authentication, credentials pulled from Secrets Manager instead of your app config, and connection handling across failovers — the proxy holds client connections while the underlying instance fails over, which shortens the error window your app sees.

Its pooling has a specific gotcha: pinning. When RDS Proxy sees session state it can't safely share, it stops multiplexing and dedicates a backend connection to that client for the rest of the session. Your pooler quietly becomes a passthrough. Check it directly:

CloudWatch → RDS → Proxy metrics
  DatabaseConnectionsCurrentlySessionPinned
  DatabaseConnectionsBorrowLatency
Enter fullscreen mode Exit fullscreen mode

If the pinned count tracks your client count, you're paying for a proxy that isn't pooling. As of mid-2026, AWS bills RDS Proxy per vCPU-hour of the underlying database instance rather than per connection or per request, with a floor for small instances — check the current pricing page, but note the shape: cost scales with your database size, not your traffic, so a small instance with bursty Lambda traffic is where it looks best and a large instance with modest connection counts is where it looks worst. RDS Proxy is the right call when IAM auth and failover behavior are requirements, not when raw pooling efficiency is the goal.

Which one should you run?

PgBouncer Supavisor RDS Proxy
Operate it yourself Yes Yes (or managed on Supabase) No
Named prepared statements in transaction mode Yes, 1.21+ Yes, version-dependent Pinning risk
Scaling model Multiple processes / instances Clustered Managed by AWS
Auth integration Postgres auth, auth_query Postgres auth IAM + Secrets Manager
Failover handling You handle it You handle it Built in
Cost shape Instance you run Instance you run, or platform Per vCPU-hour of the DB instance
Best when You want control and predictability You're on Supabase, or want a clustered pooler You're on RDS/Aurora and need IAM + failover

FAQ

What's the difference between session pooling and transaction pooling in PgBouncer?
Session pooling assigns a server connection to a client until it disconnects, preserving all session state but providing little multiplexing. Transaction pooling returns the server connection to the pool after each COMMIT, which is what lets hundreds of clients share a few dozen backends, but it breaks anything that depends on session state — prepared statements, session SETs, LISTEN/NOTIFY and session-level advisory locks.

Why do I get "prepared statement already exists" through PgBouncer?
Your driver created a named prepared statement on one server connection, then landed on a different one for the next query. Fix it by enabling max_prepared_statements in PgBouncer 1.21 or later so the pooler tracks statements per server connection, or by disabling named prepared statements in the driver (prepare_threshold=None in psycopg3, statement_cache_size=0 in asyncpg).

Does RDS Proxy replace PgBouncer?
Only partly. RDS Proxy pools connections and adds IAM authentication and failover handling, but it pins connections whenever it detects session state it can't share, which can eliminate the pooling benefit entirely. Watch the DatabaseConnectionsCurrentlySessionPinned CloudWatch metric before assuming it's pooling.

Bottom line

If you run your own Postgres and want the most predictable behavior, run PgBouncer in transaction mode with max_prepared_statements set, and watch SHOW POOLS for cl_waiting. If you're on Supabase, use Supavisor's transaction port for app traffic and the session port for migrations and listeners — that decision is already made for you. If you're on RDS or Aurora and your real requirements are IAM auth and clean failovers, RDS Proxy earns its cost, provided you check the pinned-connection metric and fix whatever session state is causing it. Whichever you pick, do the app-side audit first: SET LOCAL instead of SET, pg_advisory_xact_lock() instead of pg_advisory_lock(), and a direct connection for anything that listens.

Related reading

Top comments (0)