PgBouncer transaction mode: what still breaks, and what stopped breaking in 1.24
Most advice about transaction pooling predates PgBouncer 1.24, which turned prepared statement support on by default. Here is what the current docs say breaks, what no longer does, and why the answer depends on which pooler sits in front of your database.
Nihar Rout · · Updated · 9 min read · 13 views
PgBouncer transaction mode no longer breaks the prepared statements your driver creates for you. PgBouncer 1.24.0 turned that support on by default in January 2025, so the standard advice - disable prepared statements, add a pgbouncer=true flag - is out of date for a current PgBouncer, and still correct for Supabase's Supavisor. What breaks depends on which pooler sits in front of your database, and most pages answering this question were written before that split existed.
This post works through what the current primary documentation says, as read on October 23, 2026: the pooling-mode feature map, the version where prepared statements changed, the differences between PgBouncer, Supavisor and managed poolers, and the checks worth running before you change a connection string.
What pgbouncer transaction mode actually does
PgBouncer's pool_mode setting has three values, and the configuration reference describes them in one line each. Session pooling, the default, means the "Server is released back to pool after client disconnects." Transaction pooling means the "Server is released back to pool after transaction finishes." Statement pooling releases it after each query, and in that mode "Transactions spanning multiple statements are disallowed."
That single difference is the whole story. In transaction mode, two statements your application sends a second apart can run on two different Postgres backends. Anything a backend remembers between transactions - a session variable, a listener, a temporary table, a session-level lock - is either gone or belongs to another client.
PgBouncer does not try to paper over this. Its reset query, which defaults to DISCARD ALL, is skipped entirely in transaction pooling, because in that mode "clients must not use any session-based features, since each transaction ends up in a different connection." There is a server_reset_query_always switch to run it anyway, and the docs are blunt about what you get: it is "for working around broken setups" and it "changes non-deterministic breakage to deterministic breakage: Clients always lose their state after each transaction." In our view that is a diagnostic, not a fix - if you need it in production, the application is using session state it should not be using.
What never works in transaction pooling
PgBouncer publishes a feature map by pooling mode. These are the entries it marks as never supported under transaction pooling, per the feature list:
| Feature | Transaction pooling | Why it fails |
|---|---|---|
| SET and RESET | Never | The setting lives on a backend you will not get again |
| LISTEN | Never | The listener is registered on one backend only |
| WITH HOLD cursors | Never | The cursor outlives the transaction, the connection does not |
| SQL-level PREPARE and DEALLOCATE | Never | Not tracked or rewritten by PgBouncer |
| Temporary tables with PRESERVE or DELETE ROWS | Never | They survive commit; the connection is released at commit |
| LOAD | Never | Loads into one backend's session |
| Session-level advisory locks | Never | Held by a session you cannot return to |
Four things on the near side of that line are worth knowing, because they are what make transaction mode usable at all. Protocol-level prepared statements work, provided max_prepared_statements is not zero. NOTIFY works, even though LISTEN does not. Cursors declared WITHOUT HOLD work. Temporary tables created ON COMMIT DROP work.
One caveat on NOTIFY: Neon's connection pooling documentation lists LISTEN and NOTIFY together as unsupported on its pooled connection, while PgBouncer's own map marks NOTIFY as working. We would take the platform's list as authoritative for that platform and test the behavior you depend on rather than assume the upstream default survived the hosting.
Prepared statements: added in 1.21, on by default since 1.24
The dates matter here, because they explain why so much published advice is wrong. The PgBouncer changelog records protocol-level named prepared statement support arriving in 1.21.0 on October 16, 2023 ("Add support for protocol-level named prepared statements!"), and 1.24.0 on January 10, 2025 flipping the default: "Enable prepared statement support by default, max_prepared_statements is now set to 200 by default." The current release as of October 2026 is 1.26.1, from October 14, 2026.
So on a PgBouncer installed in the last two years, max_prepared_statements defaults to 200, and the configuration docs say that when it is non-zero PgBouncer "tracks protocol-level named prepared statements related commands sent by the client in transaction and statement pooling mode." Set it to 0 and "prepared statement support for transaction and statement pooling is disabled."
The limit is specific, and it is where people get caught. Only the protocol-level form is tracked. The docs state plainly that "this tracking and rewriting of prepared statement commands does not work for SQL-level prepared statement commands, so PREPARE, EXECUTE and DEALLOCATE are forwarded straight to Postgres," with DEALLOCATE ALL and DISCARD ALL as the exceptions that behave correctly. If your code writes PREPARE in SQL, transaction pooling will still break it in 2026. If your driver uses the extended query protocol - which most do by default - it will not.
Why the prepared statement error shows up late
The classic symptom is an error like prepared statement "s0" already exists, or a complaint that a prepared statement does not exist, appearing in production after weeks of clean local runs. The mechanism is the multiplexing itself: with one client and no concurrency, the pool holds a single server connection and every transaction lands on the same backend, so a statement prepared once keeps working. Add parallel requests and the pool opens more backends, and the statement your driver believes is prepared is missing on the connection it gets.
Migration tools are the other common trigger, for a different reason. Prisma's PgBouncer guide says its Schema Engine "uses a single connection" and does not support pooling, and names the error you see when you run migrations through a pooler: prepared statement "s0" already exists. Its documented fix is structural rather than a flag - point the Prisma CLI at a direct connection in prisma.config.ts while Prisma Client keeps using the pooled URL. The same instinct applies to anything that expects a session: DigitalOcean's pooling docs warn that "using pg_dump on a connection pool causes errors when pool_mode is set to transaction," and tell you to run it against the cluster directly.
Prisma's version guidance has also moved with PgBouncer. Its page scopes the pgbouncer=true flag to "PgBouncer versions below 1.21.0" and recommends not setting it otherwise, which is the opposite of what most tutorials still say. If you inherited that flag from a 2023 blog post and your pooler is current, it is the setting to question first. Teams who hit this while unpicking a managed connection layer may also recognize the pattern in our write-up of the Prisma Accelerate retirement.
Your pooler may not be PgBouncer - check which one you have
This is the part that makes a single answer impossible. Four widely used setups, as their own documentation describes them in October 2026:
| Pooler | Default mode | Protocol-level prepared statements |
|---|---|---|
| PgBouncer 1.24+ self-hosted | Session; transaction is opt-in per database | Yes, 200 statements per connection by default |
| Neon | PgBouncer in transaction mode on the pooled host | Yes, max_prepared_statements set to 1000 |
| Supabase Supavisor | Transaction mode on port 6543, session mode on 5432 | No in transaction mode |
| DigitalOcean managed Postgres | Transaction, and "cannot be modified after creation" | Not stated in its pooling docs |
Neon's documentation says it uses PgBouncer to reach "up to 10,000 concurrent connections," supports protocol-level prepared statements, and does not support SQL-level PREPARE and EXECUTE. It also publishes the numbers you need for capacity planning: a 120-second query_wait_timeout, and a default pool size of 90% of max_connections per user per database.
Supabase is the case where the old advice still holds. Its connection guide reserves transaction mode on port 6543 for "serverless and edge functions, which open many short-lived connections," states that "transaction mode does not support prepared statements," and sends persistent backends, migrations and backups to a direct connection or to session mode on 5432. Its troubleshooting page lists the per-library switches that follow from that: pgbouncer=true for Prisma, prepare: false for Drizzle, prepare_threshold set to None for psycopg, statement_cache_size=0 for asyncpg, prepareThreshold=0 for JDBC, and omitting the name property in node-postgres queries.
In other words, the same code can be correct on one host and broken on another. We would treat "disable prepared statements" as a Supavisor-specific instruction, not a general rule about transaction pooling.
Should you use session or transaction pooling?
Our read of the trade-off, not a vendor's: transaction pooling is worth it when the number of clients is large or unpredictable - serverless functions, edge runtimes, many containers each holding idle connections - because that is the only case where multiplexing buys you anything. A fixed set of long-lived application servers that already run their own in-process pools gets much less from it, and pays the compatibility cost anyway. DigitalOcean notes that transaction mode "is the most common option and is selected for you by default," but a default is not a recommendation for your workload.
Skip transaction pooling, or keep a second direct connection beside it, if any of this is true:
- You use LISTEN for job queues or cache invalidation.
- You take session-level advisory locks for leader election or cron de-duplication.
- You set session variables at connect time - search_path, timezone, or a role claim used by row-level security.
- You create temporary tables that need to survive a commit.
- You run migrations, pg_dump or long psql sessions through the same endpoint as the app.
What to check before you change a connection string
- Find out which pooler you actually have, and its version. The behavior above splits on exactly this.
- Read your pooler's max_prepared_statements value rather than assuming 200 - a managed host may set it, and a config file inherited from 2023 may still carry a 0.
- Check whether your driver uses the extended query protocol or writes SQL-level PREPARE. The first survives transaction pooling; the second does not.
- Search the codebase for SET, LISTEN, pg_advisory_lock and CREATE TEMP TABLE, and decide for each whether it moves to a direct connection or changes shape.
- Give migrations and admin tooling their own direct URL, and keep it out of the application's pool.
- Load-test with real concurrency. A prepared-statement bug in this area is typically invisible at a pool size of one.
If you are choosing the layer rather than debugging it, the hosted Postgres options differ most in exactly the places this post covers - pooler, mode, and what they admit does not work. Upfynd lists 247 products in Databases & Infrastructure as of October 2026, including the managed Postgres services named here, and PgBouncer-style tooling sits alongside the rest of the open source and developer tooling listings. Read the pooling page of any candidate before you commit a connection string to it.
Frequently asked questions
What is PgBouncer transaction mode?
It is a pooling mode in which PgBouncer returns the Postgres connection to the pool as soon as each transaction finishes, instead of holding it for the whole client session. That lets many clients share few backends, but each transaction may run on a different backend, so session state does not persist.
Does PgBouncer support prepared statements in transaction mode?
Yes, for protocol-level named prepared statements. PgBouncer added that support in 1.21.0 and enabled it by default in 1.24.0, with max_prepared_statements set to 200. SQL-level PREPARE, EXECUTE and DEALLOCATE are still forwarded straight to Postgres and still break under transaction pooling.
Why do I get prepared statement already exists?
Because a statement prepared on one Postgres backend does not exist on another, and transaction pooling hands your next transaction to whichever backend is free. It appears under concurrency, not in single-client testing. Migration tools that expect one session, such as Prisma's Schema Engine, trigger it reliably.
Should I use session or transaction pooling?
Transaction pooling pays off when many short-lived clients open connections, such as serverless or edge functions. Session pooling is the safer choice when a fixed set of application servers already pools connections, or when you rely on LISTEN, session advisory locks, SET variables or temporary tables that outlive a commit.
What does not work in transaction pooling?
PgBouncer lists SET and RESET, LISTEN, WITH HOLD cursors, SQL-level PREPARE and DEALLOCATE, temporary tables with PRESERVE or DELETE ROWS, LOAD, and session-level advisory locks as never supported. NOTIFY, WITHOUT HOLD cursors, ON COMMIT DROP temporary tables and protocol-level prepared statements do work.
Does Supabase support prepared statements on the pooler?
Supabase's documentation says Supavisor in transaction mode on port 6543 does not support prepared statements, while direct connections and session mode on port 5432 do. Supabase publishes per-library switches to disable them, including pgbouncer=true for Prisma and prepare false for Drizzle, as of October 2026.
Products in this article
About the author
Nihar Rout
CEO & Co-founder