Two client connections, opened one after the other through PgBouncer transaction mode, asked the same database whose rows they were allowed to see, and the second one got the first one's answer. It had set nothing, it had never met the first caller, and the rows it read belonged to that caller's tenant. Any runbook that keeps a tenant key in a session variable sits one pooler away from this, and since 28 July the MCP protocol carries no session of its own, so a tool call from an agent arrives in this shape by default.
The setup is the one most multi-tenant guides teach. A row level security policy reads current_setting('app.tenant', true), each request or tool call opens with SET app.tenant, and on a connection nobody else is using the rows that come back belong to whoever asked for them. Through the pooler, the first call still behaves as written.
SET app.tenant = 'a';
SET
SELECT current_setting('app.tenant') AS tenant;
tenant
--------
a
(1 row)
SELECT tenant, body FROM docs;
tenant | body
--------+----------------
a | alpha invoice
a | alpha contract
(2 rows)
A second client connection, opened after the first one had closed and setting nothing of its own, then asks the database who it is working for and what it may read.
SELECT current_setting('app.tenant', true) AS inherited_tenant;
inherited_tenant
------------------
a <-- set by the caller before it
(1 row)
SELECT tenant, body FROM docs;
tenant | body
--------+----------------
a | alpha invoice
a | alpha contract
(2 rows)
Both callers ran on one backend, as did the two after them that switched the tenant to b and inherited it. When caller A's implicit transaction ended, PgBouncer released that backend and handed it over without sending anything in between that would have cleared app.tenant. Two changes made that shape the ordinary one. On 28 July 2026 the MCP specification took the session out of the protocol, its announcement stating that "Each request now travels on its own, carrying its protocol version, client identity, and client capabilities in _meta", and hosted Postgres now hands out a pooled endpoint beside the direct one. Writing on 17 September, Christophe Pettus reached the neighboring problem in The Call Is Coming From Inside the Session, where a session undoes the limits placed on it. The failure below starts a step earlier, with a session that was never this caller's.
Key Takeaways
- The next client connection read the previous caller's
app.tenant, itsSET ROLE, itssearch_pathand itsstatement_timeout, and it could also reach the temp tables, SQLPREPAREstatements,WITH HOLDcursors,LISTENsubscriptions and session-level advisory locks left on that backend. - Whatever belongs to a transaction dies with it, so
SET LOCAL,set_config(..., true)andpg_advisory_xact_lockwere gone before the next caller arrived. - Handing a backend over, PgBouncer restores
client_encoding,datestyle,timezone,standard_conforming_strings,application_nameand whatevertrack_extra_parametersnames, which is why a leakedtimezoneis not on that list. - Once any caller has touched a custom parameter,
current_setting('app.tenant', true)returns the empty string in place of NULL for the life of that backend, which breaks anIS NULLtest and aCOALESCEdefault. - With
default_pool_sizeat 1, one client idling inside a transaction took the pool away from everybody, and the caller behind it was disconnected withFATAL: query_wait_timeout. - Wrap each request in one explicit transaction, set the tenant inside it with
SET LOCAL, and leave nothing on the backend that theCOMMITdoes not remove, which rules outLISTENand SQL-levelPREPAREon a pooled connection altogether.
Everything below was measured on PostgreSQL 18.6 and PgBouncer 1.25.2 in Docker on 18 September 2026, against one pooled backend except where a transcript names a second one, with every experiment starting from an explicit DISCARD ALL reset.
PgBouncer transaction mode and what the next client inherits
Start with the process id, because it settles one question before the others get interesting. A single statement, run by three client connections that opened and closed one after another through the pooler, came back the same every time.
SELECT pg_backend_pid();
pg_backend_pid
----------------
137
(1 row)
Backend 137 answered all three of them, while two clients that skipped the pooler and dialed the server got 184 and 191, a new process each, the picture everyone carries when reasoning about what a session owns. Which pieces of that session survive a handover is the open question, and the answer depends on the kind of state involved.
| State the caller leaves behind | Lives in | After the caller's transaction ends | Next client on that backend | Transcript |
|---|---|---|---|---|
SET app.tenant, set_config(..., false) | session | still set | LEAKS, reads a | the tenant key |
SET LOCAL app.tenant, set_config(..., true) | transaction | discarded | LOST, reads the empty string | the tenant key |
SET ROLE tenant_a | session | still set | LEAKS, current_user is tenant_a | current_user |
SET LOCAL ROLE tenant_b | transaction | discarded | LOST, back to agent | current_user |
SET search_path = sandbox, public | session | still set | LEAKS, one query text reaches another table | current_user |
SET statement_timeout | session | still set | LEAKS, upward and downward | the cap |
options=-c statement_timeout=0 at login | startup packet | never applied | REFUSED at login, or dropped | the cap |
CREATE TEMP TABLE | session temp schema | still there | LEAKS, with the rows in it | FETCH from a stranger |
PREPARE (SQL level) | session | still there | LEAKS, EXECUTE runs it | FETCH from a stranger |
Parse/Bind, max_prepared_statements = 200 | pooler, per client | re-parsed per client | REFUSED, client disconnected | FETCH from a stranger |
Parse/Bind, max_prepared_statements = 0 | session | still there | LEAKS, silent wrong result | FETCH from a stranger |
DECLARE ... CURSOR WITH HOLD | session | outlives COMMIT by design | LEAKS, with its rows | FETCH from a stranger |
LISTEN events | backend | still subscribed | LEAKS, notifications follow the backend | FETCH from a stranger |
pg_advisory_lock(42) | session | still held | LEAKS, and stacks | advisory locks |
pg_advisory_xact_lock(43) | transaction | released | LOST | advisory locks |
application_name, timezone, IntervalStyle | session, tracked per client | restored for the new client | PUT BACK, at their defaults | below |
Read the table by its second column, since anything belonging to a transaction is gone when the transaction ends and that is the only cleanup a transaction-mode pool performs unasked. Everything else stays where the caller left it, because the reset query that would remove it is not sent in this mode, which PgBouncer's config page states under server_reset_query_always, reading "When this setting is off (default), the server_reset_query will be run only in pools that are in sessions-pooling mode".
Against all of that, one row in the table is the pooler working for the new client. PgBouncer keeps a short list of parameters per client connection and restores them at handover, the default list being "client_encoding, datestyle, timezone, standard_conforming_strings and application_name" plus whatever track_extra_parameters adds. Caller A set three of those to values nobody picks twice, and the next client found the defaults.
SHOW application_name;
application_name
------------------
psql
(1 row)
SHOW timezone;
TimeZone
----------
Etc/UTC
(1 row)
SHOW intervalstyle;
IntervalStyle
---------------
postgres
(1 row)
Reproducing the leak on a laptop is harder than it looks, and server_round_robin is why. PgBouncer reuses server connections last-in-first-out by default, so a lone client against an idle pool keeps landing on the backend it just released and reads its own state back. Give the pool two backends and one competitor for them, and a single client connection moves between backends between two of its own statements.
SET app.tenant = 'a';
SET
SELECT pg_backend_pid() AS pid_now, current_setting('app.tenant', true) AS tenant_now;
pid_now | tenant_now
---------+------------
676 | a
(1 row)
\! sleep 8
SELECT pg_backend_pid() AS pid_now, current_setting('app.tenant', true) AS tenant_now;
pid_now | tenant_now
---------+------------
667 | <-- same client connection, other backend
(1 row)
SELECT tenant, body FROM docs;
tenant | body
--------+------
(0 rows)
Caller A never disconnected during any of that, its SET having gone to backend 676, while a holder released 667 during the sleep and the pooler handed 667 out next as the most recently freed connection. On a connection that had set a tenant eight seconds earlier, zero rows is the policy working correctly against an empty one. A question posted to Stack Overflow on 15 September reports the same mechanism on an application-side pool, with a small share of concurrent requests returning another tenant's rows and the asker unable to reproduce the fault using SET LOCAL inside a transaction.
The tenant key, and the empty string that is not NULL
Doing the same work inside a transaction is what fixes this, and the fix is worth watching before the failures pile up. Caller A wrapped one call the way a per-call shape wants it wrapped.
BEGIN;
BEGIN
SET LOCAL app.tenant = 'a';
SET
SELECT tenant, body FROM docs;
tenant | body
--------+----------------
a | alpha invoice
a | alpha contract
(2 rows)
COMMIT;
COMMIT
The manual grants no exceptions, saying of SET LOCAL that "The effects of SET LOCAL last only till the end of the current transaction, whether committed or not". The next client through the pooler confirms it and shows the part that catches people out.
SELECT current_setting('app.tenant', true) IS NULL AS tenant_is_unset;
tenant_is_unset
-----------------
f
(1 row)
SELECT count(*) AS visible_rows FROM docs;
visible_rows
--------------
0
(1 row)
Zero visible rows is the right answer, so look instead at the f above it, because current_setting('app.tenant', true) returned something that is neither NULL nor a. It returned the empty string and will go on returning it for the life of that backend, because a two-part name that has been touched once stays defined as a placeholder, the kind PostgreSQL says has "no function until the module that defines them is loaded".
A second entrance to the same trap runs through set_config. Pass true for is_local with no BEGIN anywhere, and the function returns the value it was handed while the statement after it reads nothing.
SELECT set_config('app.tenant', 'a', true);
set_config
------------
a
(1 row)
SELECT current_setting('app.tenant', true) AS same_call_next_statement;
same_call_next_statement
--------------------------
(1 row)
Each statement in autocommit is its own transaction, so is_local = true covers precisely the statement that ran it. Correct as that is, it makes the least useful possible reading of SET LOCAL in Postgres, because a tool call sending its set_config and its query as two round trips has lost the setting by the time the query arrives.
SET LOCAL can only be used in transaction blocks
On a direct connection with one session and nobody else in it, the empty string arrives at the first RESET ALL and never leaves.
-- fresh backend, parameter never set in this session
SELECT current_setting('app.tenant', true) IS NULL AS is_null, current_setting('app.tenant', true) = '' AS is_empty;
is_null | is_empty
---------+----------
t |
(1 row)
SET app.tenant = 'a';
SET
RESET ALL;
RESET
SELECT current_setting('app.tenant', true) IS NULL AS is_null, current_setting('app.tenant', true) = '' AS is_empty;
is_null | is_empty
---------+----------
f | t
(1 row)
DISCARD ALL;
DISCARD ALL
SELECT current_setting('app.tenant', true) IS NULL AS is_null, current_setting('app.tenant', true) = '' AS is_empty;
is_null | is_empty
---------+----------
f | t
(1 row)
DISCARD ALL does not bring NULL back, which matters because DISCARD ALL is what a pooler would run if it ran anything. Since the value is never NULL again, code testing current_setting('app.tenant', true) IS NULL to decide whether a request identified itself is right exactly once per backend, for the first caller that backend ever serves. Wrapping the same call in COALESCE(..., 'default') fares worse, because the empty string is a value and goes straight through to whatever relied on the fallback. The policy survives all of it, since tenant = '' matches no row.
Issuing SET LOCAL outside a transaction block is the other road to an empty value, and the server does warn.
SET LOCAL app.tenant = 'c';
SET
SELECT current_setting('app.tenant', true) AS after_set_local_outside_txn;
WARNING: SET LOCAL can only be used in transaction blocks
after_set_local_outside_txn
-----------------------------
(1 row)
The server answered SET and left the value empty, having sent the warning wherever the driver sends warnings. A client library that swallows notices makes the exchange look successful, and the statement behind it reads a tenant nobody set.
PgBouncer can track additional parameters, so the obvious repair is to name app.tenant in track_extra_parameters. Trying that here took a restart, because the setting is not reloadable and SHOW CONFIG reports it as changeable = no; with IntervalStyle, app.tenant in place, caller A set the tenant to a on backend 822, and the next client connection landed on 822 and read this.
SELECT pg_backend_pid(), current_setting('app.tenant', true) AS inherited_tenant;
pg_backend_pid | inherited_tenant
----------------+------------------
822 | a
(1 row)
The control in the same run behaved, with IntervalStyle set by one caller and reported as postgres by the next, so the tracking works and simply cannot see this parameter. One line of the documentation says why, that "The only parameters that can be tracked are ones that Postgres reports to the client", and an app.* placeholder never appears in the parameter status messages the server sends back.
current_user says tenant_a, and nobody asked
People push roles into the session for the same purpose, with SET ROLE tenant_a before the query and RESET ROLE after it. After the first half of that, caller A read tenant_a | agent. A new client connection then asked the same question without going near a role.
SELECT current_user, session_user;
current_user | session_user
--------------+--------------
tenant_a | agent
(1 row)
session_user is still agent, since that is who authenticated, while current_user is somebody else's tenant, and every privilege check and every policy evaluated for this caller now runs against that identity. Caller C, working inside BEGIN; SET LOCAL ROLE tenant_b; ... COMMIT;, saw tenant_b | agent while its transaction was open, and caller D on the same backend afterwards was itself again.
SELECT current_user, session_user;
current_user | session_user
--------------+--------------
agent | agent
(1 row)
Harder to notice is search_path, because nothing in the next caller's SQL looks wrong. Caller A pointed it at a schema of its own, sandbox, which holds a table also named docs with no policy on it, and the following client connection ran the query it always runs.
SHOW search_path;
search_path
-----------------
sandbox, public
(1 row)
SELECT * FROM docs;
tenant | body
--------+-------------------------------------------------
x | this row lives in sandbox.docs, not public.docs
(1 row)
The query text did not change while the table did, and the policy on public.docs never came into it because public.docs was never read. Any later query resolving an unqualified name on a caller's behalf inherits whichever search_path the last caller left behind, and that reaches further than the tool call in front of you, down into the plans an ORM built months ago for a query nobody has reread since.
The cap the previous caller removed
A role carrying statement_timeout = '2s' is the standard piece of containment, and an earlier article here works through what a session can do to its own limits with USERSET parameters and the startup packet. Put a pooler underneath and the same act acquires a second victim.
SHOW statement_timeout;
statement_timeout
-------------------
2s
(1 row)
SET statement_timeout = 0;
SET
Caller B, a new client connection that issued no SET at all, inherited the missing cap, and a four-second query ran to completion.
SHOW statement_timeout;
statement_timeout
-------------------
0
(1 row)
SELECT setting, source FROM pg_settings WHERE name = 'statement_timeout';
setting | source
---------+---------
0 | session <-- nothing on this connection ran SET
(1 row)
\timing on
Timing is on.
SELECT pg_sleep(4);
pg_sleep
----------
(1 row)
Time: 4007.830 ms (00:04.008)
\timing off
Timing is off.
That source column reads session, which is pg_settings recording that some client set the value on this backend without recording which one, so a watchdog reading the column sees a session that lifted its own timeout while looking at a caller that did nothing. The leak runs downhill as readily, with caller C lowering the cap to 100 milliseconds and caller D cancelled on an ordinary half-second query.
SHOW statement_timeout;
statement_timeout
-------------------
100ms
(1 row)
SELECT pg_sleep(0.5);
ERROR: canceling statement due to statement timeout
unsupported startup parameter in options
The quieter route into the same parameter is the libpq options field in the startup packet, and that is the one a pooler stops. A connection carrying options='-c statement_timeout=0' never reaches a login.
psql: error: connection to server at "bouncer" (172.26.0.3), port 5432 failed: FATAL: unsupported startup parameter in options: statement_timeout
PgBouncer logged the same text as a warning and closed the client before it had a pool. Sent straight to the server the identical string was accepted and SHOW statement_timeout came back 0, so the refusal belongs to the pooler. Making the pooler permissive buys the caller nothing either, since a variant with ignore_startup_parameters widened to cover options let the login through and dropped the switches.
SHOW statement_timeout
statement_timeout
-------------------
2s
(1 row)
SELECT setting, source FROM pg_settings WHERE name='statement_timeout'
setting | source
---------+--------
2000 | user
(1 row)
Two seconds with source = user is the role's own default, untouched. Between the refusal and the shrug, in the two configurations tested here, there is no way through a PgBouncer for a startup-packet parameter it does not understand, so the pooler turns out to be the one component in this setup that takes the operator's side, and a connection string that would have lifted the cap on a direct connection arrives at the role's own two seconds instead.
Things you can FETCH from a stranger
Parameters are the polite half of the problem, because the rest of a session is objects with contents, and those change hands with their data intact, starting with the temp table, a real table in a schema private to the backend that nothing in the handover removes.
relation "scratch" already exists
Caller A created scratch and wrote one row into it. The next client connection read that row, then tried to create its own scratch table under the name every scratch table has.
SELECT * FROM scratch;
who
---------------------
written by caller A
(1 row)
CREATE TEMP TABLE scratch (who text);
ERROR: relation "scratch" already exists
The read handed a stranger's data to a caller that had asked for its own, and then the create failed against a temp schema this caller did not know it was sharing, two separate faults out of one five-line transcript. Declaring the table ON COMMIT DROP is the form that survives a transaction pool, because then the drop belongs to the transaction.
Worse in one respect is a holdable cursor, which exists precisely to outlive its transaction. Caller A declared one over a three-row table inside BEGIN ... COMMIT, and caller B fetched all of it out of a cursor it had never declared, from a table it had never named.
FETCH ALL FROM c;
body
------------
note one
note two
note three
(3 rows)
The subscription LISTEN creates belongs to the backend process and not to the client that issued it, so a caller that never issued it can find itself subscribed to somebody else's channel, and whichever client holds that backend when a NOTIFY fires is the one that receives the payload.
SELECT * FROM pg_listening_channels();
pg_listening_channels
-----------------------
events
(1 row)
prepared statement "s1" already exists
Prepared statements split into two mechanisms with different outcomes, and one branch produces the only silent wrong answer in this article. SQL-level PREPARE creates a session object like any other, so after caller A prepared q, caller B executed a statement it had never prepared and then collided with the name.
EXECUTE q(41);
?column?
----------
42
(1 row)
PREPARE q AS SELECT 1;
ERROR: prepared statement "q" already exists
Protocol-level Parse and Bind, which is what a driver sends for a named statement, is the case PgBouncer has handled since 1.21.0, the release whose changelog announced "support for protocol-level named prepared statements", and which 1.24.0 turned on by default with max_prepared_statements at 200. At that default the pooler keeps its own map of client statement names and re-parses them on whichever backend the client lands on, so when caller B bound a name it had never parsed, the pooler dropped the connection.
\bind_named s1 21 \g
FATAL: prepared statement did not exist
server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
connection to server was lost
Rudeness aside, the disconnect is the right answer, and caller C parsed its own s1 afterwards and got tripled back, so the tracking did the job it was added for. Turn it off with max_prepared_statements = 0, and caller B bound a statement it never parsed and was answered with 42, while caller C was told the name was taken and bound its parameter regardless.
SELECT $1::int * 3 AS tripled \parse s1
\bind_named s1 7 \g
ERROR: prepared statement "s1" already exists
doubled
---------
14 <-- caller A's statement, caller C's parameter
(1 row)
The column header gives it away, since caller C asked for tripled and seven times three is twenty-one, while the number that came back is fourteen, seven times two, computed by caller A's statement against caller C's parameter. Nothing raised an error the application could catch, and a value wrong by half again went back to whatever had asked the question.
you don't own a lock of type ExclusiveLock
Where one job has to run at a time, the usual instrument is a session-level advisory lock, and here it fails in the direction that costs money. Caller A took lock 42 and disconnected, after which caller B asked for the same lock and waited for nothing.
SELECT pg_try_advisory_lock(42) AS got_it;
got_it
--------
t
(1 row)
SELECT pid, objid, granted FROM pg_locks WHERE locktype = 'advisory';
pid | objid | granted
-----+-------+---------
137 | 42 | t
(1 row)
It got the lock because the backend was already holding it, and the manual explains the arithmetic, noting that "Multiple session-level lock requests stack, so that if the same resource identifier is locked three times there must then be three unlock requests to release the resource in advance of session end". Two callers now believe they hold a mutex, and a third can settle the argument for both of them.
SELECT pg_advisory_unlock(42) AS first, pg_advisory_unlock(42) AS second, pg_advisory_unlock(42) AS third;
first | second | third
-------+--------+-------
t | t | f
(1 row)
WARNING: you don't own a lock of type ExclusiveLock
Two unlocks succeeded against a lock this caller never took, and when the third found nothing left, the warning became the only trace of any of it. Take the transaction-level function instead and none of that happens, as caller D found with pg_advisory_xact_lock(43) inside a transaction, after which caller E counted zero advisory locks. Any scheduler holding a session lock across a transaction pool is holding a mutex the pool can hand to the next stranger on the same backend for free.
One idle transaction, and everyone waits
The availability failure needs no leak at all, and at a default_pool_size of 1 the pool here is small enough to watch the mechanism in one screen, which is also the shape any deployment reaches on the day its concurrency outruns its pool, whatever the configured size happens to be. A holder opened a transaction and sat there after a single statement.
SELECT pid, usename, state, now() - xact_start AS in_txn_for, left(query, 30) AS query FROM pg_stat_activity WHERE usename = 'agent';
pid | usename | state | in_txn_for | query
-----+---------+---------------------+-----------------+--------------------------------
144 | agent | idle | | select 'via bouncer0 ok', pg_b
485 | agent | idle in transaction | 00:00:02.341321 | SELECT 1;
(2 rows)
query_wait_timeout
An idle in transaction backend is not released, so everybody else queues behind it, and the queue has a deadline.
SELECT 1 AS reached_the_server;
FATAL: query_wait_timeout
server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
connection to server was lost
(caller B waited 5 s)
query_wait_timeout was lowered to 5 seconds here so the demonstration would finish, while the PgBouncer default is 120 seconds, two minutes of a client holding a query it has already sent, with SHOW POOLS reporting cl_active 1, sv_active 1 and sv_idle 0 for the pool for as long as the wait lasted.
The failure people brace for next did not happen. A client killed mid-transaction, with kill -9 landing on its process while an uncommitted temp table sat on the backend, handed its half-finished work to nobody.
2026-09-18 09:36:19.963 UTC [1] LOG C-0x7f1aca25c3b0: app/agent@172.26.0.8:49456 closing because: client unexpected eof (age=2s)
2026-09-18 09:36:19.963 UTC [1] LOG S-0x7f1aca2063d0: app/agent@172.26.0.2:5432 closing because: client disconnect while server was not ready (age=188s)
Seeing the client's end of file, PgBouncer found its server connection in a state that was not ready and threw the backend away without trying to tidy it. SHOW SERVERS returned no rows, and the caller behind all of this was given a new process.
SELECT pg_backend_pid();
pg_backend_pid
----------------
773
(1 row)
SELECT count(*) AS temp_tables_named_t_in_txn FROM pg_class WHERE relname = 't_in_txn';
temp_tables_named_t_in_txn
----------------------------
0
(1 row)
Backend 773 has never heard of t_in_txn, and in the one case that involves a half-finished transaction, where inconsistent state would be worse than leaked state, the pooler closes the connection instead of recycling it.
Making a tool call a transaction
The repair for all of it appeared near the top of this article, in the block where caller A wrapped one call. Open an explicit transaction at the start of the call, set the tenant inside it with SET LOCAL or with set_config and is_local true, add SET LOCAL ROLE where a policy tests an identity, run the work, commit, and let the call own nothing that the commit does not take away.
Follow it and most of the table settles itself, with advisory locks becoming the transaction-level functions, cursors declared WITHOUT HOLD or consumed inside the transaction that declared them, temp tables carrying ON COMMIT DROP, LISTEN kept off a pooled connection entirely, and prepared statements coming from the driver's protocol support with max_prepared_statements above zero instead of a SQL PREPARE that nothing will deallocate.
Finding the calls that still run outside a transaction is the tedious part, and the server log is the honest tool for it, since log_statement = 'all', set on the agent role from a superuser session, writes every statement with its backend pid, and a SET with no BEGIN ahead of it on the same pid is the call to fix. Hosted poolers such as Supabase's Supavisor are separate implementations that this stand did not measure, so run the pg_backend_pid() probe and the two-connection SET test against them before trusting either answer.
Blunter instruments exist and each carries a price worth knowing first. Switching server_reset_query_always on sends DISCARD ALL after every transaction, and caller B on such a pool found an empty tenant and no advisory locks, with both of caller A's objects gone as well.
SELECT current_setting('app.tenant', true) AS inherited_tenant;
inherited_tenant
------------------
(1 row)
SELECT count(*) AS advisory_locks_held FROM pg_locks WHERE locktype = 'advisory';
advisory_locks_held
---------------------
0
(1 row)
EXECUTE q(41);
ERROR: prepared statement "q" does not exist
SELECT * FROM scratch;
ERROR: relation "scratch" does not exist
LINE 1: SELECT * FROM scratch;
^
A client that prepared its statements once and built a temp table for a multi-step job loses both at every commit with no way to ask for an exception, and the last two errors above are the bill, which is why PgBouncer frames the option as a way of "working around broken setups that run applications that use session features over a transaction-pooled PgBouncer", one that "changes non-deterministic breakage to deterministic breakage". Session pooling keeps the session whole, at the cost of the multiplexing that was the point of running a pooler in the first place. A direct connection per call reaches the same safety by a slower road, since every call now pays for its own connection setup.
Whatever the client does, two pieces belong on the server side. Write the policy so an empty string fails closed, which tenant = current_setting('app.tenant', true) already manages, and keep COALESCE and IS NULL out of the path that decides whether a request identified itself. Give every identity its own login role as well, so that a watchdog filtering pg_stat_activity on usename has something to match that no session can rewrite, the control Pettus argues for from the other direction. Workloads that never share a backend never meet this matrix, and a batch loader gets there by opening a direct connection for the length of its run. Seedfast is built for that shape, loading a generated dataset over connections it opens itself and closes on exit, which is why the seeding guides for Neon, Supabase and Prisma Postgres all point at the direct string and away from the pooled one, whereas an MCP server multiplexing many agents through one pooled endpoint is the shape this matrix describes.
On PgBouncer's own feature page, transaction pooling "breaks client expectations of the server by design" and "can be used only if the application cooperates by not using non-working features", so none of what was measured here is a defect in the pooler, and that has been the deal from the start. The party doing the cooperating has changed, because a tool call assembled by a model arrives with no memory of the one before it, and nobody in that loop was ever assigned to read the connection's history and decide what to clean up.
Frequently asked questions
Why does SET leak between requests on a pooled connection?
The pooler gives the connection back to the pool when your transaction ends, and nothing runs on it in between. A plain SET writes a session-level value, the session belongs to the backend process and not to your client, and server_reset_query is sent only in session-pooling mode unless server_reset_query_always is turned on. Measured here, a caller that issued no such statement of its own read the previous caller's app.tenant off the backend they shared. The Stack Overflow question above describes the same symptom on an application-side pool, where a connection some earlier request configured is handed to the next one.
Is SET LOCAL enough for row level security with PgBouncer?
Yes, provided the SET LOCAL and the query it protects sit inside one explicit transaction. Its effect ends at COMMIT or ROLLBACK, so the value cannot reach the next client on that backend, and the caller that followed in this test saw zero visible rows. Two things spoil it, since running SET LOCAL outside a transaction block leaves you a warning and an empty value, and set_config(..., true) in autocommit covers only the statement that called it, so a query sent on the next round trip reads nothing.
Does PgBouncer support prepared statements in transaction mode?
Protocol-level named statements, yes, since 1.21.0 by the changelog and on by default since 1.24.0, through max_prepared_statements, which defaults to 200 and makes the pooler responsible for re-issuing a client's Parse on whichever backend serves its next transaction. Bind a name the pooler has no parse for and it drops your connection instead of answering. SQL-level PREPARE is a different thing entirely and is not tracked, so the statement stays on the backend where the next caller can EXECUTE it or collide with its name. Set max_prepared_statements to zero and you get the worst result measured on this stand, a Bind from one caller running another caller's statement and returning a plausible wrong number.
What does server_reset_query_always do, and why do the docs discourage it?
With it on, PgBouncer runs server_reset_query in transaction mode too, so DISCARD ALL lands after every transaction and the backend returns to the pool carrying nothing. On this stand that removed the leaked tenant, the advisory lock, the prepared statement and the temp table in one step. The documentation calls it a way of working around broken setups because it cannot tell state a caller leaked from state a caller still needs, so a client doing legitimate multi-statement work loses its objects at every commit. It earns its place in the config only for as long as the application code still needs fixing.
Related guides
- Your agent can turn off its own kill switch explains USERSET parameters, the startup-packet route that this pooler refuses, and why a watchdog has to live in another session.
- Seeding a Supabase database sets the session pooler on port 5432 against the transaction pooler on 6543 and says which of them a seed survives.
- Seeding a Neon database shows why the pooled hostname breaks a long script that the unpooled string carries through.
- Prisma Postgres seeds covers the pooled endpoint, prepared statements, and the error a seed hits halfway.