The silent bugs that only appear in production: prepared statements, temp tables, and serverless connection spikes.
P-11 — Connection Pooling Failure Modes: PgBouncer, Serverless, and the Edge
What this module covers: P-8 introduced PgBouncer. This module goes deeper into the failure modes that silently break applications when pooling is misconfigured — the session vs transaction mode divide that breaks prepared statements and temporary tables, the specific ways Next.js and serverless environments create connection spike patterns that exhaust pools, and the correct architecture for each deployment context.
Why Connection Pooling Breaks Things
A connection pool is not a transparent proxy. It multiplexes multiple application connections onto fewer Postgres connections, which means application-level state tied to a connection is not preserved between statements.
This creates a class of bugs that are invisible in development (where the pool is often not used, or uses session mode) and only appear in production under load. The symptoms look like data corruption, missing data, or mysterious errors — not "this is a pooling problem."
Session vs Transaction vs Statement Mode: The Exact Breakage
Session Mode
One Postgres backend is assigned to one application connection for the entire session. The application and the Postgres backend have a 1:1 relationship for the session's duration.
What works: everything. Prepared statements, temporary tables, advisory locks, SET parameters, LISTEN/NOTIFY, cursors, transaction control.
Pool savings: minimal — you need roughly as many Postgres backends as peak concurrent application connections. Session mode pooling is mainly useful for reducing connection establishment overhead (TLS handshake, authentication).
[pgbouncer]
pool_mode = session
Transaction Mode
A Postgres backend is held only during an active transaction. Between transactions, the backend is returned to the pool and may be given to a different application connection.
What breaks:
1. Prepared statements:
-- Application connection A prepares a statement on backend B1:
PREPARE get_tx AS SELECT * FROM transactions WHERE id = $1;
-- Transaction ends. Backend B1 returned to pool.
-- Application connection A starts a new transaction.
-- Gets backend B2 (different backend).
-- Application tries to use the prepared statement:
EXECUTE get_tx(12345);
-- ERROR: prepared statement "get_tx" does not exist
-- (It exists on B1, not on B2)
2. Temporary tables:
-- Application creates a temp table on backend B1:
CREATE TEMP TABLE work_items AS SELECT id FROM jobs WHERE status = 'pending';
-- Transaction ends. Backend B1 returned to pool.
-- Next transaction gets backend B2.
SELECT * FROM work_items;
-- ERROR: relation "work_items" does not exist
-- (Temp table exists only on B1's session)
3. Session-level SET parameters:
-- Application sets timezone on backend B1:
SET timezone = 'America/New_York';
-- Next transaction may get B2 — timezone is back to default.
-- Date/time calculations silently produce wrong results.
4. Advisory locks:
-- Application acquires session-level advisory lock on B1:
SELECT pg_advisory_lock(12345);
-- Transaction ends. B1 returned to pool.
-- Lock is held on B1 indefinitely (session-level locks don't release on transaction end).
-- Another transaction on B1 gets the lock unexpectedly.
-- The original application connection can never release it (it's now on B2).
5. LISTEN/NOTIFY:
-- Application subscribes on B1:
LISTEN new_transactions;
-- Transaction ends. B1 returned to pool (or given to another client).
-- Notifications arrive on B1 but the original application connection is no longer on B1.
-- Application never receives the notification.
[pgbouncer]
pool_mode = transaction
# Use this mode for high-throughput OLTP
# Never use prepared statements or temp tables, or use Postgres-side prepared statements
Statement Mode
Each statement gets its own backend. Multi-statement transactions are broken: each BEGIN, UPDATE, COMMIT goes to a different backend.
BEGIN; -- backend B1
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- backend B2
COMMIT; -- backend B3
-- The UPDATE was not committed — each statement ran in its own autocommit transaction
Statement mode is almost never correct for application use. It exists for very specific query-routing scenarios. Avoid it.
The Prepared Statement Problem: The Correct Solutions
If you need prepared statements with transaction-mode PgBouncer, there are three correct approaches:
Solution 1: Disable Prepared Statements at the Driver Level
Most database drivers support disabling prepared statements. When disabled, every query is sent as a simple query string without preparation.
// Node.js with pg (node-postgres)
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
// Disable prepared statements — compatible with PgBouncer transaction mode
// In pg, this means avoiding pool.connect() + client.query() with named statements
});
// Use simple queries (no named prepared statements)
await pool.query('SELECT * FROM transactions WHERE id = $1', [id]);
// pg sends this as a simple parameterized query, not a named prepared statement
# Python with psycopg2
import psycopg2
conn = psycopg2.connect(dsn, options="-c statement_timeout=30s")
# psycopg2 uses server-side prepared statements by default for % params
# Use execute_values or mogrify for non-prepared execution
# Python with asyncpg — disable prepared statement caching
conn = await asyncpg.connect(dsn, statement_cache_size=0)
Solution 2: Use max_prepared_statements
PgBouncer 1.21+ supports transparent prepared statement handling in transaction mode:
[pgbouncer]
pool_mode = transaction
# Enable prepared statement tracking (PgBouncer 1.21+)
# PgBouncer tracks which prepared statements exist on which backends
# and ensures the correct backend is used for each EXECUTE
max_prepared_statements = 100
This makes prepared statements work transparently in transaction mode — PgBouncer handles the routing. Check your PgBouncer version before relying on this.
Solution 3: Session Mode for Prepared-Statement-Heavy Connections
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion