Migrating from Session to Transaction Pooling

This guide is part of Transaction vs Statement Pooling Trade-offs. Session pooling is where most PgBouncer deployments start, because it is compatible with everything: each client keeps one server connection for its whole session, so nothing about the application has to change. It also provides almost no multiplexing — the backend connection count still tracks the client connection count — so the first time the fleet grows, the proxy stops helping:

# session mode: 400 clients, 400 backend connections
SHOW POOLS;
 database | user       | cl_active | cl_waiting | sv_active | sv_idle | pool_mode
 orders   | orders_app |       400 |         37 |       398 |       0 | session
FATAL:  sorry, too many clients already

Transaction pooling collapses those 400 clients onto a few dozen backends, at the cost of breaking anything that assumes a session persists between transactions. The objective is a migration that finds every such assumption before the switch, changes the application where needed, and cuts over with a fast way back.

Rapid incident diagnosis

Confirm the current mode and its ratio. The console shows both:

SHOW POOLS;       -- pool_mode per database
SHOW DATABASES;   -- pool_size, max_connections

A cl_active close to sv_active is session mode’s signature: one server connection per client.

Audit the application for session-scoped behaviour. Five categories break under transaction pooling, and each has a searchable shape:

# 1. session-level SET (not SET LOCAL)
grep -rnE "\bSET +(SESSION +)?[a-z_]+ *(=|TO)" --include=*.py --include=*.rb --include=*.java --include=*.ts . | grep -v "SET LOCAL"
# 2. session advisory locks
grep -rn "pg_advisory_lock\b" --include=* . | grep -v xact
# 3. LISTEN / NOTIFY
grep -rn "LISTEN \|add_listener\|on_notify" --include=* .
# 4. temporary tables and WITH HOLD cursors
grep -rniE "create +temp(orary)? +table|with hold" --include=* .
# 5. server-side prepared statements
grep -rn "prepareThreshold\|statement_cache_size\|prepare_threshold\|max_prepared_statements" --include=* .

Check the database for evidence rather than trusting the grep. Session state in use shows up in the server’s own views:

-- advisory locks held outside a transaction
SELECT l.pid, a.application_name, a.state
FROM pg_locks l JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'advisory' AND a.state <> 'active';

-- sessions listening for notifications
SELECT pid, application_name FROM pg_stat_activity WHERE wait_event = 'ClientRead' AND query ILIKE 'listen%';

-- temporary tables in use
SELECT n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname LIKE 'pg_temp%';

Measure what the migration would buy. The ratio transaction pooling can reach depends on how long transactions are:

backend_needed = transactions_per_second × mean_transaction_seconds ÷ 0.7

At 1,200 transactions per second averaging 4 ms: 1200 × 0.004 ÷ 0.7 ≈ 7 backends for 400 clients — a ratio of about 57:1.

Backend connections in session and transaction pooling With session pooling, 400 clients require 400 backend connections. With transaction pooling and a mean transaction time of four milliseconds, the same clients need about seven. 400 client connections, 1,200 transactions per second session mode 400 transaction mode 7 the cost is that nothing may rely on a session lasting beyond one transaction which is a code change, not a configuration change backends ≈ transactions per second × transaction seconds ÷ 0.7
Session pooling holds one backend per client; transaction pooling holds one per concurrent transaction. The ratio is the whole reason for the migration.

Mathematical sizing or parameter formula

Size the backend pool from transaction concurrency:

default_pool_size = ceil( transactions_per_second × mean_transaction_seconds ÷ 0.7 )

Round up for burstiness and add reserve_pool_size for spikes. The measured mean transaction time must include application time inside explicit transactions, which is often much larger than the query time.

Client limits stay generous:

max_client_conn ≥ processes × pool_max × 1.5

Transaction duration is the lever. Because backends scale with transaction time, shortening transactions multiplies the ratio:

ratio = clients ÷ (transactions_per_second × mean_transaction_seconds ÷ 0.7)

Moving a 150 ms HTTP call out of a transaction that otherwise takes 4 ms changes the required backends by a factor of nearly 40.

Application pools can shrink after the migration. With the proxy owning reuse, the local pool needs only one process’s concurrency:

app_pool_max ≈ per-process concurrent queries, often 5–10 rather than 20–50

Plan the rollback capacity. During the migration both ports exist, and in the worst case both are in use:

worst_case_backends = session_port_clients + transaction_port_backends

The database must accept that sum for the duration of the cutover.

Backend connections against mean transaction time At 1,200 transactions per second, a four millisecond mean transaction needs seven backends, a forty millisecond mean needs sixty nine, and a one hundred and fifty millisecond mean needs two hundred and fifty seven. Backends required at 1,200 transactions per second 4 ms: 7 40 ms: 69 150 ms: 257 shortening transactions is the cheapest lever
Backends needed fall with transaction time, so the largest gains come from removing non-database work from transactions before the switch, not after.

Exact remediation & configuration

Fix the five categories before switching.

-- SET → SET LOCAL, inside a transaction
BEGIN;
SET LOCAL statement_timeout = '2s';
SELECT set_config('app.tenant_id', $1, true);
-- ...
COMMIT;

-- session advisory lock → transaction-scoped
SELECT pg_advisory_xact_lock(hashtext('nightly-invoice-run'));

Constant settings move to role defaults, where they cost nothing and survive any pooling mode:

ALTER ROLE orders_app SET application_name = 'orders-api';
ALTER ROLE orders_app SET statement_timeout = '2s';

LISTEN/NOTIFY, temporary tables outside a transaction and WITH HOLD cursors have no transaction-scoped equivalent; those code paths need a direct connection that bypasses the proxy.

Run both modes side by side. A second database entry pointing at the same server, in transaction mode, lets services migrate one at a time:

[databases]
orders          = host=orders-db.internal port=5432 dbname=orders pool_mode=session      pool_size=40
orders_txn      = host=orders-db.internal port=5432 dbname=orders pool_mode=transaction  pool_size=12

[pgbouncer]
listen_port = 6432
max_client_conn = 2000
reserve_pool_size = 4
max_prepared_statements = 200        ; PgBouncer 1.21+: keeps prepared statements working
server_reset_query = DISCARD ALL     ; applies to session mode

A service migrates by changing its database name in the connection string from orders to orders_txn — a configuration change, deployable and revertible per service.

Handle prepared statements. On PgBouncer 1.21 and later, max_prepared_statements above zero lets drivers keep using them. On older versions, disable them per driver: prepareThreshold=0 for PostgreSQL JDBC, statement_cache_size=0 for asyncpg, prepare_threshold=None for psycopg, ?pgbouncer=true for Prisma’s engine.

Point migrations and admin tools at the direct port. Advisory locks, CREATE INDEX CONCURRENTLY and session-level settings need a session:

DATABASE_URL="postgres://app@pgbouncer.internal:6432/orders_txn"   # runtime
DIRECT_URL="postgres://migrator@orders-db.internal:5432/orders"    # migrations

Shrink the application pools after the switch, not before: the proxy now owns connection reuse, and a large local pool simply inflates the client count.

Staging the cutover

A migration that flips every service at once has no useful rollback. Doing it service by service, with a rehearsal, takes longer and fails smaller.

Stage one: audit and fix. Work through the five categories with the greps and the server-side queries. Fix what has a transaction-scoped equivalent; list what does not.

Stage two: rehearse in staging. Point one service at the transaction-mode entry under production-like load and watch for the failure signatures: prepared statement "S_1" already exists, advisory locks that never release, LISTEN notifications that never arrive, or settings that appear to apply to the wrong request.

Stage three: migrate the least risky service first. A read-heavy service with no session state proves the configuration end to end with little exposure. Watch its ratio: cl_active ÷ sv_active should rise immediately.

Stage four: migrate the rest, one at a time. After each, check three numbers: the multiplexing ratio, cl_waiting, and the application’s error rate. A service whose ratio stays near 1:1 has session state that the audit missed — usually a driver behaviour rather than application code.

Stage five: retire the session entry. Once no service uses it, remove the orders entry and reclaim its pool_size for the transaction pool.

Stage Exit criterion Rollback
Audit and fix No session-scoped usage without a plan n/a
Rehearse No failure signatures under load n/a
First service Ratio rises, errors flat for 24 h Change the database name back
Remaining services Same, per service Per service
Retire session entry No clients on it for a week Re-add the entry

Keep the rollback trivial. Because the switch is a database name in a connection string, reverting one service is a deploy of one environment variable — which is what makes it safe to migrate during business hours.

Running session and transaction pooling side by side PgBouncer exposes two database entries pointing at the same PostgreSQL server, one in session mode and one in transaction mode, so services can migrate individually. orders-api billing-api reporting PgBouncer orders (session) orders_txn (transaction) PostgreSQL one server migrating a service is a change of database name; reverting is the same change back
Two database entries against the same server let each service move independently, with a rollback that is a single configuration change.

Validation & verification

The ratio improved. After each service migrates, compare client and server connections for its pool:

SELECT database, cl_active, sv_active, round(cl_active::numeric / nullif(sv_active,0), 1) AS ratio
FROM (SELECT * FROM pgbouncer.pools) p WHERE database LIKE 'orders%';

Run it from the admin console; a ratio that stays near 1 means session state is still pinning.

No session-state errors. Search application logs for the signatures over the first day: prepared statement, no such savepoint, there is no transaction in progress, or unexplained empty results from a query that follows a SET.

Advisory locks release. The query from the diagnosis section should return no rows outside active transactions.

Backends stay within the pool. On PostgreSQL, sessions from the proxy should equal roughly default_pool_size + reserve_pool_size, not the client count.

Rollback works. Before migrating the second service, actually revert the first one in staging and confirm it returns to session mode cleanly. A rollback that has never been executed is a plan, not a capability.

Frequently Asked Questions

Can one application use both modes at once?
Yes, and it is often the cleanest answer: the request path uses the transaction-mode entry, while a notification listener or a migration runner uses the session-mode entry or a direct connection. They are different connection strings in the same service.
What about statement mode?
Statement pooling returns the connection after every statement and forbids multi-statement transactions. It suits only workloads that never use an explicit transaction — rare outside specific analytics paths. The comparison is in How to Choose Between Transaction and Statement Pooling in PostgreSQL.
Does server_reset_query protect against leaked state in transaction mode?
By default it runs only in session mode. Forcing it in transaction mode with server_reset_query_always adds a round trip to every transaction and interferes with prepared statement tracking; correct application behaviour is the better protection.
How long should both entries coexist?
Long enough for every service to migrate and for a week of quiet afterwards. There is no cost to keeping the session entry other than its pool_size, so there is no reason to rush its removal.