Configuring ActiveRecord with PgBouncer Transaction Mode

This guide is part of Rails ActiveRecord Connection Pool. Rails is one of the frameworks that does not work behind transaction-mode pooling out of the box, and the reasons are specific and fixable. Three defaults break, one multi-tenant pattern is actively dangerous, and migrations need a separate path. This guide covers all four.

The first failure usually appears within minutes of switching pool_mode, and it is intermittent — which is what makes it confusing:

ActiveRecord::StatementInvalid (PG::UndefinedPsqlStatement: ERROR:
  prepared statement "a12" does not exist):
  app/models/order.rb:44:in `by_customer'

It appears under concurrency and not in testing, because at low concurrency PgBouncer often hands the same client the same backend and the prepared statement happens to be there. Raising concurrency makes reassignment likely and the error constant.

Rapid Incident Diagnosis

Three checks confirm that transaction pooling, rather than something else, is the cause.

1. Confirm the pool mode. On the PgBouncer admin console, SHOW CONFIG; and read pool_mode. If it is transaction or statement, cross-transaction backend affinity does not exist and every item below applies.

2. Confirm the error class. prepared statement "..." does not exist (SQLSTATE 26000) is the prepared-statement failure. relation "..." does not exist for a temporary table is the temp-table failure. Data appearing under the wrong tenant is the search_path failure and the most serious of the three.

3. Confirm it scales with concurrency. Run the same request path at one concurrent client and at fifty. If the error appears only at fifty, backend reassignment is the mechanism — which is exactly what transaction pooling does.

Three Rails defaults that break Prepared statements, advisory locks used by migrations, and per-connection search_path all assume backend affinity that transaction pooling does not provide. prepared_statements: true named statements bound to a backend error 26000, load-dependent → prepared_statements: false loud failure — you will notice advisory_locks: true migrations serialise on a session lock lock held on a reassigned backend → run migrations direct quiet failure — concurrent migrations per-connection search_path tenant schema set on the connection survives the commit, reaches the next tenant → SET LOCAL inside the transaction silent failure — no error at all
Three defaults, three failure volumes. The prepared-statement failure is loud and the tenant-isolation failure is silent, which is the inverse of how much each one matters.

The Three Settings

# config/database.yml — behind PgBouncer in transaction mode
production:
  adapter: postgresql
  host: pgbouncer.internal
  port: 6432
  database: orders_production
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i + 1 %>
  checkout_timeout: 3

  prepared_statements: false    # 1 — named statements cannot survive reassignment
  advisory_locks: false         # 2 — migrations must use a direct connection instead

  variables:
    application_name: orders-web        # constant per process — safe here
    statement_timeout: 5000             # server-side bound; also constant

prepared_statements: false is the setting that removes the loud failure. The cost is that PostgreSQL re-parses and re-plans each statement, which for the short, parameterised statements ActiveRecord generates is typically a fraction of a millisecond — measurably less than the network hop the proxy itself adds. PgBouncer 1.21 and later can track prepared statements and replay them onto each backend, which preserves plan reuse at the cost of proxy memory; if that is enabled with a non-zero max_prepared_statements, this setting can stay true.

advisory_locks: false stops ActiveRecord attempting to take a session-level advisory lock around migrations. That lock is what prevents two concurrent deploys running the same migration, so disabling it removes a real safety property — which is why migrations should run through a direct connection that bypasses the proxy entirely, where the lock works as intended.

The variables: block applies its settings at connection establishment. That is safe for values that are constant for the process, and unsafe for anything tenant-specific. This is the distinction the next section is about.

The Multi-Tenant Hazard

Schema-per-tenant applications set search_path to select the tenant. Doing that on the connection is the natural implementation and the dangerous one.

# DANGEROUS behind transaction pooling: the setting survives the commit
# and the next transaction on that backend inherits it.
ActiveRecord::Base.connection.execute("SET search_path TO #{tenant.schema}")

# Correct: scoped to the transaction, reverts automatically at COMMIT.
ActiveRecord::Base.transaction do
  ActiveRecord::Base.connection.execute(
    ActiveRecord::Base.sanitize_sql(["SET LOCAL search_path TO %s", tenant.schema])
  )
  yield
end

The failure mode of the first form has no error and no log line. Tenant A sets its schema, commits, and the connection returns to the proxy still carrying that setting. Tenant B is assigned the same backend, issues no SET of its own, and reads tenant A’s data — a correct-looking response containing the wrong customer’s records.

Gems that implement schema-based multi-tenancy generally use the per-connection form because it is correct without a proxy. Behind transaction pooling they need configuration or replacement, and the check is worth doing explicitly rather than assuming.

The alternative that avoids the question entirely is row-level tenancy with a tenant column and a scope, which requires no session state at all. For an application about to adopt transaction pooling, it is worth weighing.

Migrations Need a Direct Path

Migrations require session-scoped behaviour that transaction pooling cannot provide — the advisory lock above, and CREATE INDEX CONCURRENTLY, which cannot run inside a transaction block at all.

Give them their own connection specification pointing at the database directly:

production:
  primary:
    <<: *pooled_defaults
    host: pgbouncer.internal
    port: 6432
  primary_migration:
    <<: *pooled_defaults
    host: postgres.internal        # direct, bypassing the proxy
    port: 5432
    pool: 2
    advisory_locks: true           # works correctly here
    migrations_paths: db/migrate
    database_tasks: true

Running migrations against primary_migration restores every session-scoped guarantee they depend on, and it costs two connections during a deploy rather than for the life of the application. The same reasoning applies to any maintenance task using LISTEN, session advisory locks, or long-lived temporary tables.

Two paths to the same database Request traffic goes through PgBouncer in transaction mode while migrations and maintenance tasks connect directly, where session-scoped features work as intended. Puma workers many, elastic PgBouncer, transaction prepared_statements: false migration job one, at deploy time direct — advisory locks work, CONCURRENTLY works PostgreSQL one database, two access paths Two connection specifications in database.yml, one deploy-time cost of two direct connections
Request traffic gets multiplexing; migrations get session semantics. The cost is two direct connections during a deploy rather than a compromise on either side.

Validation and Verification

Test at concurrency, not at rest. A single client frequently gets the same backend and passes trivially. Force reassignment by setting default_pool_size below the test’s concurrency:

; pgbouncer.ini — for the verification run only
[databases]
orders_production = host=postgres.internal pool_size=3

[pgbouncer]
pool_mode = transaction
max_client_conn = 200
query_wait_timeout = 10

With three server connections and twenty concurrent clients, every transaction is reassigned. A test suite that passes under those conditions has exercised the path that matters.

Verify tenant isolation explicitly. The search_path failure produces no error, so it needs a deliberate assertion:

# Interleave two tenants across many transactions and assert isolation.
20.times do
  Tenant.find_each do |tenant|
    with_tenant(tenant) do
      assert_equal tenant.schema,
        ActiveRecord::Base.connection.select_value("SHOW search_path").split(",").first.strip
      assert Order.all.all? { |o| o.tenant_id == tenant.id }
    end
  end
end

Confirm the proxy is actually multiplexing. SHOW POOLS on the admin console: cl_active should greatly exceed sv_active. If the two are close, something is pinning sessions and the proxy is adding a hop for no benefit.

Common Failure Patterns & Remediation

Symptom Root Cause Exact Fix Validation
prepared statement "aNN" does not exist, load-dependent prepared_statements: true under reassignment Set it to false, or enable proxy tracking Error absent at test concurrency with a small server pool
Tenant data appears under another tenant SET search_path on the connection SET LOCAL inside the transaction Isolation assertion passes across interleaved tenants
Two deploys run the same migration advisory_locks: false with migrations through the proxy Run migrations on a direct connection spec Concurrent deploy serialises correctly
CREATE INDEX CONCURRENTLY fails Cannot run inside a transaction block Direct connection plus disable_ddl_transaction! Index builds without locking the table
cl_activesv_active on the proxy Something is pinning sessions Audit for SET, temp tables, prepared statements Ratio rises well above 1:1
Works in staging, fails in production Staging concurrency too low to force reassignment Reproduce with pool_size below test concurrency Failure reproduces before release
LISTEN-based feature stops working Notifications delivered to an unrelated backend Dedicated direct connection for notifications Notification received within the expected window

The row worth re-reading is the second. It is the only entry in the table whose failure produces no error, no log line and no failed request — just a correct-looking response containing another customer’s data. Everything else on this page can be found by watching error rates; that one has to be tested for deliberately.

Forcing reassignment in a test With a server pool larger than the test concurrency, each client keeps the same backend and the failure never appears. With a server pool smaller than concurrency, every transaction is reassigned and the failure is deterministic. pool_size 50, concurrency 5 every client keeps its backend reassignment almost never happens the suite passes and proves nothing pool_size 3, concurrency 20 backends are shared constantly every transaction is reassigned a pass here is meaningful evidence Deliberately under-sizing the server pool for the verification run is what converts an intermittent production failure into a deterministic test outcome.
A test against a generously sized proxy pool never exercises reassignment. Shrinking the server pool below test concurrency makes the failure deterministic instead of intermittent.

Frequently Asked Questions

Does prepared_statements: false measurably slow the application?
Rarely. It removes server-side plan caching for statements that are typically simple and parameterised, where planning is a fraction of a millisecond. Measure the p99 of the affected queries before and after; on a normal Rails workload the difference is smaller than the proxy’s own added latency.
Can Rails use PgBouncer 1.21’s prepared-statement tracking?
Yes. With max_prepared_statements set on the proxy, prepared_statements: true becomes safe again and plan reuse is preserved. The trade is proxy memory per tracked statement per backend, which for an application with many distinct queries can be substantial.
What happens to ActiveRecord::Base.connection.execute("SET ...") calls elsewhere in the app?
They carry the same hazard as the tenant case. Audit for bare SET statements before switching mode — anything not scoped with SET LOCAL inside a transaction will leak to the next client of that backend.
Does Sidekiq need different settings?
The same three settings, since it faces the same proxy. What differs is the pool size, which should follow Sidekiq’s concurrency rather than Puma’s thread count.
Should the local pool be smaller behind PgBouncer?
Yes, moderately. The multiplexing happens downstream, so the local pool exists only to avoid a handshake to the proxy. It still needs to cover the thread count — Rails’ rule is unchanged — but the fleet total matters far less, because it consumes proxy client slots rather than database backends.