> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# PgBouncer Transaction Mode Broke Every Prepared Statement
- URL: https://debugly.dev/pgbouncer-transaction-mode-prepared-statements/
- Published: 2026-09-07T07:30:00.000Z
- Updated: 2026-10-10T13:37:30.000Z
- Description: After switching the pooler to transaction mode, the app started failing with 'prepared statement does not exist' at random.
- Author: Rohit Bhadani
- Tags: Performance, PostgreSQL, Databases

The migration to PgBouncer was meant to fix connection exhaustion. It did. It also introduced a new failure that arrived as intermittent errors, on some queries, sometimes, and vanished when you retried.

I have lost on-call weeks to this exact shape of slow burn, so the order below is the order I fix it in.

The error was `ERROR: prepared statement "stmt_1" does not exist`. It was intermittent because whether it appeared depended on which backend connection the pooler happened to hand you, and prepared statements are per connection.

This is the single most common PgBouncer surprise, and it is a great worked example of a pooler changing the semantics of what a "connection" even means.

This was PgBouncer 1.23 in front of Postgres 16.3, with drivers using the extended query protocol, which is the default in most modern clients.

## What transaction mode actually changes

In session mode, a client that checks out a connection keeps the same backend for its whole session. Everything session scoped works: prepared statements, temporary tables, `SET` commands, advisory locks.

In transaction mode, a client keeps a backend only for the duration of a transaction. Between transactions, the backend goes back to the pool and your next transaction may run on a completely different backend.

That is the entire feature, and it is why transaction mode lets thousands of clients share dozens of backends. But it means any state that lives on a connection is gone the moment your transaction ends.

Prepared statements are exactly such state.

## Why the driver is the one getting surprised

Most drivers use the extended query protocol, which parses and plans statements server side. Many drivers cache these prepared statements per connection to avoid re parsing. The driver prepares `stmt_1` on connection A, runs a transaction, returns the connection. The next transaction lands on connection B, where the driver believes `stmt_1` exists but Postgres has never heard of it.

Hence the intermittent `does not exist`. It is not random at all. It is deterministic given the assignment of backends, which from the outside looks random.

The same class of failure hits other session state. A `SET search_path` you ran in one transaction is not present in the next. A temporary table created outside a transaction disappears. If your app depends on any of these, transaction mode has silently changed your contract.

## The fixes, from least to most invasive

**Disable driver side prepared statement caching.** Most drivers have a setting. In libpq based clients it is the `prepareThreshold` and `preparedStatementCacheSize` knobs, set to disable. In node-postgres you avoid the named statements or set the cache off. The driver then sends the full query text every time, which is always safe. You lose a little parse time and gain correctness.

**Use statement pooling.** PgBouncer has a `statement` mode, even finer than transaction, but it breaks anything that spans statements, so it is rarely the answer. Skip it unless you know why.

**Move to a driver that understands poolers.** Some drivers and ORMs detect pooled environments and behave correctly. This is the most future proof but the most work.

**Keep session mode for the connections that need session state.** It is legitimate to run two pools. Transaction mode for the high volume stateless path, session mode for the small number of connections that genuinely need session scoped features. Complexity, but honest.

## The other transaction mode traps

Prepared statements are the headline, but the same root cause produces a family of bugs worth knowing.

**`SET` does not persist.** If you `SET timezone` or `SET search_path` and expect it for the session, transaction mode will surprise you. The fix is to set what you need inside the transaction, or use connection level parameters in the connection string.

**Advisory locks and `pg_advisory_lock` are session scoped.** A lock taken in one transaction is released when the backend is returned, which may be mid use from your point of view. Use the transaction scoped variants, `pg_advisory_xact_lock`, which are designed for this.

**Temporary tables are session scoped.** Creating one and using it across transactions will fail or, worse, see a different temporary table. This is the same trap as the pool reuse bug in [flaky CI shared runner state](https://debugly.dev/flaky-ci-shared-runner-state/), but at the database layer.

**Last insert id via sequences is fine, but `currval` is session scoped.** `currval` returns the last value for the session, and in transaction mode your next call may be on a different session. Use `RETURNING` instead, which is better practice anyway.

It is worth saying plainly that none of these traps make transaction mode a bad choice. The mode exists because backend connections are the scarce resource at scale, and giving them up between transactions is precisely how a hundred clients share ten backends. The traps are the price of that efficiency, and the price is payable if you know it is being charged.

## How to confirm you have this

Check the pooler mode:

```bash
psql -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS"

```

Or read the config:

```bash
grep pool_mode /etc/pgbouncer/pgbouncer.ini

```

Then, on the app side, watch for the intermittent `prepared statement ... does not exist`. A quick confirmation is to enable `log_statement` on Postgres and observe a `Parse` for a statement name that was never sent to that backend.

## The mental model

A pooler in transaction mode is telling you the truth: you do not have a connection, you have a transaction scheduler. Any feature whose semantics are "for this connection" is now "for this transaction". Audit your codebase for session scoped Postgres features the day you turn it on, not the day the errors start.

The win is real. Transaction mode is usually the right choice for a connection starved service, and it is the reason [postgres too many connections](https://debugly.dev/postgres-too-many-connections-fix/) gets solved at scale. Just go in knowing the contract has changed, and that the errors it produces are not flukes but the new semantics, working as designed.