MVP Factory
ai startup development

PgBouncer transaction mode: why your ORM is silently breaking

KW
Krystian Wiewiór · · 5 min read

Meta description: PgBouncer transaction mode silently breaks ORM features — prepared statements, advisory locks, SET LOCAL. Here are the exact mitigations and pool sizing formula.

Tags: backend api microservices architecture cleanarchitecture


TL;DR

PgBouncer’s transaction-mode pooling is the right default for high-concurrency backends — but it silently breaks prepared statements, advisory locks, SET LOCAL, and temporary tables. Most ORMs depend on at least one of these. The fix is not to switch to session mode. The fix is to know exactly which ORM features to disable, which to re-implement, and how to size your pool correctly.


The trap every team falls into

A team hits connection exhaustion at scale, adds PgBouncer in transaction mode, ships it, and watches mysterious bugs appear two weeks later in staging. Same failure sequence, different company.

A PostgreSQL instance comfortable with 100 real connections can serve 10,000+ application-level “connections” under transaction-mode pooling — because each server connection is only held for the duration of a single transaction, not an entire session. That’s the promise. The cost: anything session-scoped disappears between statements.


What transaction mode actually does

PostgreSQL connections carry state. Transaction mode strips that state between pool checkouts:

FeatureSession modeTransaction mode
Prepared statements (named)PersistedBroken
SET LOCAL / SET variablesPersistedBroken
Advisory locksPersistedBroken
Temporary tablesPersistedBroken
LISTEN/NOTIFYPersistedBroken
Simple queries / unnamed stmtsWorksWorks

The problem is that Hibernate, SQLAlchemy, Prisma, and Exposed all use at least two of these features by default without advertising it loudly.


ORM-specific failure modes

Hibernate / JPA

Hibernate’s PreparedStatementCache caches named prepared statements server-side. In transaction mode, PgBouncer routes your next statement to a different backend — one that has never seen that prepared statement. You get ERROR: prepared statement "S_1" does not exist.

Disable server-side prepared statement caching at the driver level by appending prepareThreshold=0 to your JDBC URL:

jdbc:postgresql://host/db?prepareThreshold=0

For pg JDBC 42.2.9+, the pgBouncer=true URL parameter handles this automatically, switching the driver to protocol-level unnamed prepared statements.

SQLAlchemy

SQLAlchemy’s autocommit=False default keeps a transaction open across multiple ORM operations. Combined with SET LOCAL search_path, this silently leaks schema context between requests. The safe configuration disables prepared statement caching at the driver layer:

engine = create_engine(
    url,
    pool_pre_ping=True,
    connect_args={"options": "-c statement_timeout=30000"}
)

psycopg3 users should additionally set prepare_threshold=0 on the connection to disable named prepared statements at the protocol level.

Prisma

Prisma uses interactive transactions that hold a connection open with BEGIN — directly compatible with transaction mode. However, Prisma’s advisory lock-based migration system will deadlock or silently fail under transaction pooling. Run migrations against a direct connection, never through PgBouncer.


The pool sizing formula

Most teams over-provision connections trying to prevent exhaustion, which causes the opposite problem — connection storms under PostgreSQL’s process-per-connection model.

The formula from production data:

pool_size = (num_cores * 2) + effective_spindle_count

For a modern cloud instance (8 vCPU, NVMe SSD — count as 1 spindle):

pool_size = (8 × 2) + 1 = 17 connections per PgBouncer node

Add a 10–20% overflow buffer via reserve_pool_size. Before calculating per-pod allocation, nail down your topology. If PgBouncer runs as a sidecar (one instance per application pod), each pod independently owns the full pool and the total PostgreSQL max_connections budget is 17 × pod_count. If PgBouncer runs as a shared proxy (one or a few centralized nodes), the 17-connection budget is divided across all pods:

per_pod_pool = floor(17 / pod_count) + 2  # 2 for reserve

The math differs significantly between the two topologies — conflating them is a common source of both under- and over-provisioning.

This feels aggressively small. It works because PostgreSQL thrives under low connection counts — contention for shared buffers drops, query planner cache hit rates increase, and the process-scheduling tax disappears.


The mitigation architecture

Four things that have to be right for transaction mode to hold up in production:

  1. Migrations run on a direct connection, bypassing PgBouncer entirely.
  2. Advisory locks move to Redis, or use pg_try_advisory_lock inside a dedicated single-connection pool separate from the main one.
  3. Move all SET LOCAL calls to pool-level search_path configuration via PgBouncer’s server_reset_query.
  4. Force unnamed, protocol-level prepared statements at the driver layer for every ORM in the stack.

Conclusion

Three things worth doing before your next deploy:

  1. Audit your ORM’s prepared statement strategy before enabling transaction mode. Set log_min_duration_statement=0 for one hour and grep for prepared statement.*does not exist — if it appears, your driver needs reconfiguration before production traffic hits.

  2. Use the (cores × 2) + spindles formula, and know your topology first. A 17-connection pool serving 500 application threads is correct behavior, not under-provisioning — but only if you’ve correctly modeled whether PgBouncer is a sidecar or a shared node.

  3. Isolate workloads that require session semantics. Migrations, advisory locks, and LISTEN/NOTIFY consumers belong on a separate unpooled or session-mode connection. One session-scoped operation shouldn’t force the entire service onto the wrong pooling mode.


Share: Twitter LinkedIn