Handling Session-Level Features Under Transaction Pooling

This guide is part of Transaction vs Statement Pooling Tradeoffs. Transaction pooling is what makes a proxy valuable: a server connection is assigned to a client only for the duration of a transaction, so a few dozen backends can serve thousands of clients. Everything that breaks under it breaks for one reason — anything whose state lives in the session rather than the transaction is no longer reliably yours.

The failures are nasty because they are intermittent. Under light load a client often gets the same server connection back and the code appears to work; under load it gets a different one and the state has vanished. Code that passes every test can fail in production at peak, which is exactly the profile of a bug that survives for months.

What has session scope

Feature Scope Behaviour under transaction pooling
SET (plain) session Applies to whichever backend served that statement; lost or leaked afterwards
SET LOCAL transaction Safe — reverts at commit or rollback
pg_advisory_lock session Held on a backend you no longer own; cannot be released reliably
pg_advisory_xact_lock transaction Safe — released at commit or rollback
LISTEN / NOTIFY session Notifications arrive on a backend that is no longer yours
CREATE TEMP TABLE session Invisible to the next transaction; leaks onto a shared backend
WITH HOLD cursors session Not available
Server-side prepared statements session Name not found on a different backend
SET SESSION AUTHORIZATION, SET ROLE session Leaks to the next client of that backend — a security problem
Sequence currval session Undefined on a different backend
Session-level application_name session Persists onto the next client’s queries

The rows marked as leaking are the more serious half. It is not only that your state disappears — it is that your state arrives at somebody else’s transaction. A SET ROLE left on a server connection is inherited by the next client that receives it, which is a privilege escalation, not merely a bug.

Session state outlives the transaction that set it Client A's transaction sets a session parameter on server connection 1 and commits. The proxy returns connection 1 to the pool. Client B's next transaction is assigned connection 1 and inherits the parameter it never set. Session state does not go back in the pool — it stays on the connection client A transaction SET statement_timeout = 60s COMMIT server connection 1 returned to the pool still carrying the setting client B transaction inherits a 60 s timeout it never asked for SET LOCAL statement_timeout = 60s reverts at COMMIT, so connection 1 goes back clean and client B is unaffected The same substitution works for every case in this article: replace the session-scoped form with the transaction-scoped one, or move the state out of the database entirely.
Session state persists on the server connection after your transaction ends. The transaction-scoped variant is what makes it safe.
Two scopes, one of which the proxy can honour Transaction-scoped state begins and ends inside a transaction, which is exactly the unit a transaction-mode proxy assigns a server connection for. Session-scoped state outlives that unit and therefore outlives your ownership of the connection. The proxy owns the connection for exactly one transaction transaction scope SET LOCAL pg_advisory_xact_lock cursors without WITH HOLD reverts at commit or rollback session scope SET, SET ROLE pg_advisory_lock LISTEN, temp tables, WITH HOLD persists on the server connection Every replacement in this section is the same substitution: move the state from the right column to the left. Where no transaction-scoped equivalent exists, the state has to move out of the database entirely.
Transaction-scoped constructs fit the proxy's unit of assignment. Session-scoped ones do not.

Replacements, feature by feature

Session parameters

Replace SET with SET LOCAL inside the transaction:

BEGIN;
SET LOCAL statement_timeout = '60s';
SET LOCAL work_mem = '128MB';
SELECT /* the expensive report query */ ...;
COMMIT;   -- both settings revert here

For settings that must apply to every connection rather than to one transaction, put them on the role or the database instead — the proxy’s server connections will inherit them at connect time and they never need to be set per transaction:

ALTER ROLE reporting SET work_mem = '128MB';
ALTER ROLE app_web  SET statement_timeout = '8s';

This is the better answer generally: role-level defaults are declarative, survive restarts, and cannot leak between clients.

Advisory locks

Replace pg_advisory_lock with pg_advisory_xact_lock. The transaction-scoped variant is released automatically at commit or rollback, which is exactly the semantics transaction pooling can support:

BEGIN;
SELECT pg_advisory_xact_lock(hashtext('nightly-invoice-run'));
-- protected work
COMMIT;   -- lock released

If the lock genuinely needs to outlive a transaction — a long-running singleton job, for instance — an advisory lock is the wrong mechanism under transaction pooling. Use a row in a table with an owner and an expiry, renewed by the holder, which is inspectable and recoverable in a way an orphaned advisory lock is not.

LISTEN and NOTIFY

There is no transaction-scoped equivalent; LISTEN is inherently session state. Two workable options:

Bypass the proxy for the listener. Open a direct connection to PostgreSQL for the notification listener only, and route all normal traffic through the proxy. One extra backend for a listener process is affordable, and it keeps the multiplexing benefit for everything else.

Move to an external queue. If you are already running Redis, Kafka, or SQS, the notification path belongs there. LISTEN/NOTIFY has no durability and no delivery guarantee across a reconnect, so a queue is usually an upgrade rather than a compromise.

Note that NOTIFY itself is fine — it is transactional and delivered at commit. Only the listening side needs a session.

Temporary tables

A temp table created in one transaction is invisible in the next and leaks onto a shared backend. Replacements, in order of preference:

  • A CTE, if the data is only needed within one query.
  • An unlogged table with a session or request identifier column, cleaned up at the end of the transaction. Unlogged tables skip WAL, so they are fast, and they are visible across transactions by design.
  • A temp table created and dropped inside a single transaction, which is safe because it never outlives the borrow — but be aware it still writes catalog entries on the shared backend, and heavy use causes catalog bloat.

Prepared statements

Server-side prepared statements are named objects in a session. Under transaction pooling a PREPARE on one backend is not found on another, giving prepared statement "S_1" does not exist.

Modern PgBouncer (1.21 and later) supports max_prepared_statements, which tracks named statements and re-prepares them on whichever server connection is assigned. Enable it if your driver relies on server-side prepares:

[pgbouncer]
pool_mode = transaction
max_prepared_statements = 200

Otherwise, disable server-side prepares in the driver — prepareThreshold=0 for the PostgreSQL JDBC driver, statement_cache_size=0 for asyncpg, prepare_threshold=None for psycopg 3, binary_parameters=yes and simple protocol for some Go drivers. The specifics are in PgBouncer Transaction vs Statement Pooling.

Cursors

DECLARE ... WITH HOLD needs the cursor to survive the transaction, which it cannot. Cursors declared without WITH HOLD are fine — they live inside the transaction.

Django’s QuerySet.iterator() uses server-side cursors by default on PostgreSQL, which is why DISABLE_SERVER_SIDE_CURSORS = True is required behind a transaction-mode proxy. SQLAlchemy’s stream_results has the same shape. When you need to iterate a large result set without a holdable cursor, keyset pagination in a loop of short transactions is the reliable alternative.

Detecting the problem before production does

Two techniques catch these before they become an intermittent incident.

Set a small pool and shuffle it. In a staging environment, configure the proxy with default_pool_size = 2 and server_lifetime = 30. Server connections then churn constantly and any session-state dependency fails quickly and reproducibly rather than once a week at peak.

Audit the server-side view. Query for state that should not exist between transactions:

-- advisory locks held by backends not currently in a transaction
SELECT a.pid, a.state, l.objid, l.classid
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory'
  AND a.state <> 'active';

-- temporary schemas left behind on shared backends
SELECT nspname FROM pg_namespace WHERE nspname LIKE 'pg_temp_%';

-- non-default settings sitting on idle backends
SELECT pid, application_name, setting
FROM pg_settings, pg_stat_activity
WHERE name = 'statement_timeout' AND state = 'idle';

Anything the first two queries return under transaction pooling is a bug. The technique for reading these views in general is in PostgreSQL Server-Side Connection Diagnostics.

Common failure patterns and remediation

Symptom Underlying cause Remediation
prepared statement "S_1" does not exist Server-side prepares across multiplexed backends Enable max_prepared_statements, or disable prepares in the driver
Statement timeout differs between identical requests SET leaked from another client Use SET LOCAL, or set defaults on the role
Advisory lock never released Session-scoped lock on a backend you no longer hold Use pg_advisory_xact_lock, or a table-based lease
Notifications stop arriving after a while LISTEN on a rotated server connection Dedicated direct connection, or an external queue
relation "tmp_x" does not exist Temp table from a previous transaction Keep temp tables inside one transaction, or use an unlogged table
Queries run as the wrong role SET ROLE leaked onto a shared backend Never use session SET ROLE; use SET LOCAL ROLE inside the transaction
cursor "c" does not exist WITH HOLD cursor under transaction pooling Keyset pagination across short transactions
Works in staging, fails at peak in production Low concurrency reuses the same backend and hides the bug Force churn with a small pool and short server_lifetime

When to use session pooling instead

Sometimes the honest answer is that a workload does not fit transaction pooling. A migration tool that holds advisory locks across statements, a job runner that depends on LISTEN, or a legacy application that sets session parameters everywhere will not be worth rewriting.

Run those on a separate proxy pool in session mode, pointed at the same database:

[databases]
orders       = host=db.internal dbname=orders pool_mode=transaction pool_size=25
orders_admin = host=db.internal dbname=orders pool_mode=session      pool_size=5

The session-mode pool gives up multiplexing — one server connection per client for the client’s whole session — which is why it must be small and reserved for the workloads that need it. Everything else keeps the transaction-mode pool and its ratio. Sizing both against the same ceiling is covered in configuring PgBouncer pool_size and max_client_conn.

Confining session mode to the workloads that need it Most traffic runs through a transaction-mode pool with a large client-to-server ratio. A second, small session-mode pool serves the migration and job workloads that genuinely need session state. One database, two pools, two different trades transaction pool thousands of clients, 25 backends application traffic no session state, full multiplexing session pool 5 backends, one per client migrations and jobs advisory locks, LISTEN, temp tables Switching the whole proxy to session mode to silence one error gives up the multiplexing you deployed it for. Both pools draw on the same database ceiling, so size them together rather than separately.
A small session-mode pool alongside the transaction-mode one confines the cost to the workloads that need it.

Operational boundary

Transaction pooling is a trade, and the trade is not negotiable: you get a large client-to-server ratio and you give up session state. No configuration recovers both. What you can do is confine the cost — route session-dependent workloads to their own small session-mode pool, replace session-scoped constructs with transaction-scoped ones everywhere else, and make the substitution a review checklist item rather than something rediscovered per incident.

The one thing not to do is set pool_mode = session globally to make an error go away. That eliminates the multiplexing you deployed the proxy for, and the fleet’s connection count reverts to what it was before — usually while everyone believes the proxy is still protecting the database.

Frequently Asked Questions

Does SET LOCAL work everywhere SET does?
For runtime configuration parameters, yes. It applies for the remainder of the current transaction and reverts at commit or rollback. It must be issued inside an explicit transaction; outside one it has no lasting effect.
Are advisory locks unusable under transaction pooling?
Session-scoped ones are. pg_advisory_xact_lock and its variants are transaction-scoped and work correctly. For locks that must outlive a transaction, use a table-based lease.
Can I use LISTEN at all?
Not through a transaction-mode proxy. Use a dedicated direct connection for the listener, or move the notification path to an external queue.
Do temp tables work if I create and drop them in one transaction?
Yes, though they still create catalog entries on the shared backend. Heavy use across many transactions causes catalog bloat, so prefer a CTE or an unlogged table where possible.
Why does this work in staging and fail in production?
Low concurrency tends to return the same server connection to the same client, so session state appears to persist. Force churn with a small pool size and short server lifetime to reproduce it.
Is a session-mode pool a reasonable escape hatch?
Yes, for the specific workloads that need it, kept small and separate. Switching the whole proxy to session mode gives up the multiplexing that justified the proxy.
What about SET ROLE for row-level security?
Use SET LOCAL ROLE inside the transaction. Session-scoped SET ROLE leaks to the next client of that backend, which is a privilege problem rather than a correctness one.