Rails ActiveRecord Connection Pool

This guide is part of Framework Integration & Connection Lifecycle. ActiveRecord’s pool is unusual among the frameworks covered here in that its correct size is not primarily a database question — it is a question about Puma. The pool serves threads within one process, so a pool smaller than the thread count guarantees threads will queue against each other, and a pool larger than the thread count wastes connections that no thread can use.

That relationship makes ActiveRecord sizing simpler than most, and it makes the surrounding arithmetic — Puma workers multiplied by replicas multiplied by pool size — the part that goes wrong. A Rails deployment with 8 replicas, 4 Puma workers and a pool of 5 opens 160 backends, and none of those three numbers appears in database.yml.

Key operational takeaways:

  • pool: must be at least Puma’s threads maximum, or threads within one process queue against each other for no reason.
  • The pool is per process: total backends are replicas × puma_workers × pool, and only the last term lives in database.yml.
  • ActiveRecord checks out on first query and holds until the end of the request, so hold time is request duration on the default configuration.
  • ActiveRecord::ConnectionTimeoutError after checkout_timeout seconds is a pool-exhaustion error, and the database is usually healthy when it fires.
  • Background jobs need their own pool sizing — Sidekiq’s concurrency, not Puma’s thread count, is the relevant number in that process.
Pool, threads, workers and replicas Each Puma worker process holds one ActiveRecord pool serving its threads. Total backend connections are the product of replicas, workers per replica and pool size, only the last of which is set in database.yml. one Puma worker process thread 1 thread 2 thread 3 thread 4 pool: 5 — one per thread, plus one × 4 Puma workers each forks its own pool 20 per replica set by WEB_CONCURRENCY, not by database.yml × 8 replicas 160 backends from a database.yml that reads "pool: 5" plus whatever Sidekiq is holding Two of the three multipliers live outside the Rails application entirely WEB_CONCURRENCY is an environment variable and replica count is a deployment manifest — neither is visible to a Rails developer reading database.yml
Only the innermost multiplier is in `database.yml`. The other two sit in environment configuration and deployment manifests, which is why the total is so often unknown.

Foundational Mechanics

ActiveRecord maintains one ConnectionPool per process, per connection specification. A thread that issues a query checks out a connection from that pool and holds it until the end of the request, when Rails’ ActiveRecord::ConnectionAdapters::ConnectionManagement middleware returns it. In a Puma worker with four threads, four connections can be in use simultaneously and a fifth thread would wait.

The checkout is per thread, not per request in the abstract, and it is reentrant: a thread that already holds a connection and queries again reuses the same one. That is what makes the “pool at least equals threads” rule sufficient rather than merely necessary — no thread ever needs two.

checkout_timeout bounds how long a thread waits. Its default of 5 seconds is more reasonable than most frameworks’ defaults, but it is still worth checking against the request timeout: a Puma worker holding a thread for five seconds waiting on a pool is a thread not serving anything else, and with a small thread count that is a meaningful fraction of capacity.

The reaping_frequency setting controls a background thread that reclaims connections owned by threads that have died — a safety net for the case where a thread is killed while holding a connection. It defaults to 60 seconds and is worth leaving on; the alternative is a connection that is never returned.

# config/database.yml
production:
  adapter: postgresql
  database: orders_production
  pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>   # ties the pool to the thread count
  checkout_timeout: 3                              # below the request timeout
  reaping_frequency: 60
  idle_timeout: 300                                # release connections a quiet worker is not using
  variables:
    statement_timeout: 5000                        # server-side bound on a single statement
    application_name: orders-web                   # attributable in pg_stat_activity

Tying pool to RAILS_MAX_THREADS — which Puma also reads for its thread count — is the idiomatic way to keep the two in sync, and it is the default in a generated Rails application for exactly that reason. Overriding one without the other is the most common way the relationship breaks.

Operational Boundary: Pool mechanics within a Rails process are covered here. Puma’s own tuning — worker count, thread count, and their relationship to available memory — is a web-server concern that this pool must follow rather than lead.

Precision Sizing & Timeout Orchestration

The sizing rule has two halves, and only the first is about Rails.

Within a process, pool must be at least the Puma thread maximum. Setting it lower means threads contend for connections that exist purely to serve them, which is pure loss — the connections are already allocated to this process. Setting it higher than the thread count is harmless but pointless in a pure web process, because no fifth thread exists to use a fifth connection.

The “plus one” that some deployments add covers the case where something other than a request thread queries — a periodic health check, a metrics collector, or a Rails-internal background thread. It is cheap insurance and worth including.

Across processes, the total is the product described above, and it must fit inside the service’s connection allowance at maximum scale. When it does not, the levers in order of preference are: reduce Puma worker count in favour of threads (which is usually also better for memory), reduce replica count, or introduce a multiplexing proxy. Reducing pool below the thread count is not on the list — it makes each process slower without reducing the peak count meaningfully.

Deployment Shape Threads Workers Pool Per Replica
Small, threaded 5 1 6 6
Standard 5 2 6 12
Memory-constrained 3 4 4 16
Thread-heavy (JRuby) 16 1 17 17
Sidekiq worker n/a 1 concurrency + 2 concurrency + 2

The last row is the one most often wrong. Sidekiq’s concurrency setting is its thread count, so a worker process running 10 concurrent jobs needs a pool of at least 10 — and it reads the same database.yml as the web tier, so a pool sized for five Puma threads will cause ConnectionTimeoutError in a Sidekiq process running ten. Setting the pool from an environment variable that differs per tier is the standard fix.

Operational Boundary: The relationship between pool size and thread count is covered here. Choosing the thread and worker counts themselves is a Puma sizing exercise driven by memory and CPU rather than by connections.

Diagnostics & Telemetry

ActiveRecord exposes pool state through ActiveRecord::Base.connection_pool.stat, which returns a hash with the numbers that matter:

ActiveRecord::Base.connection_pool.stat
# => { size: 5, connections: 5, busy: 4, dead: 0, idle: 1, waiting: 0, checkout_timeout: 3.0 }

waiting is the definitive saturation signal, exactly as pending is in HikariCP: it counts threads currently blocked in checkout, and in a healthy process it is zero. busy at size with waiting at zero is full utilisation, not a fault. dead counts connections whose owning thread died before returning them — a non-zero value means the reaper has work to do and points at threads being killed mid-request.

Exporting these on an interval is a few lines and is the single highest-value observability addition a Rails service can make:

# config/initializers/pool_metrics.rb — sample every 10 seconds
Thread.new do
  loop do
    s = ActiveRecord::Base.connection_pool.stat
    StatsD.gauge("db.pool.busy",    s[:busy])
    StatsD.gauge("db.pool.idle",    s[:idle])
    StatsD.gauge("db.pool.waiting", s[:waiting])   # alert on this
    StatsD.gauge("db.pool.dead",    s[:dead])
    sleep 10
  end
end

On the database side, pg_stat_activity grouped by application_name gives the per-tier session count, which is how the multiplication above is verified against reality rather than assumed. Setting distinct application_name values for the web and worker tiers — through the variables: block in database.yml — makes that grouping meaningful.

Reading connection_pool.stat Busy at size with zero waiting is full utilisation. Any sustained waiting is saturation. A non-zero dead count means threads are being killed while holding connections. busy = size, waiting = 0 every thread is querying full utilisation healthy — do not alert on this waiting > 0 threads blocked in checkout saturation, unambiguous check pool vs thread count first dead > 0 owning thread died holding one threads killed mid-request check request timeouts and OOM kills connections < size pool has not grown yet normal after start-up ActiveRecord opens lazily The first check when waiting is non-zero is always pool size versus thread count In a Rails service, the two being out of step is a more likely cause than genuine database contention
Four readings from one hash. The `waiting` field is the unambiguous saturation signal, and in Rails its most common cause is a pool smaller than the thread count.

Multiple Databases and Role-Based Connections

Rails 6 introduced multiple-database support, and it multiplies pools in a way the database.yml syntax makes easy to miss. Each entry under an environment is a separate connection specification with its own pool, so a configuration with a primary and a replica opens two pools per process — and both counts must fit the budget.

production:
  primary:
    adapter: postgresql
    database: orders_production
    pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
    variables:
      application_name: orders-web-primary
  primary_replica:
    adapter: postgresql
    database: orders_production
    host: replica.internal
    replica: true
    pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
    variables:
      application_name: orders-web-replica

That configuration opens ten connections per process, not five. With four Puma workers and eight replicas it is 320 backends rather than 160, and nothing in the file says so. Sizing each pool independently — a larger primary and a smaller replica pool, weighted by measured traffic — is both cheaper and more accurate than duplicating the same value.

connected_to(role: :reading) switches which pool a block uses, and the switch is per thread. A request that reads from the replica and then writes holds a connection from each pool for the overlapping portion, which is a detail worth knowing when the numbers do not add up: two concurrent connections from one thread is possible here in a way it is not in the single-database case.

Two operational cautions apply. connects_to with a shards: mapping multiplies again — one pool per shard per process — and a service with eight shards and a pool of five holds forty connections per process before any replica is counted. And automatic role switching, enabled through ActiveRecord::Middleware::DatabaseSelector, sends reads to the replica only after a delay following a write, which means read traffic distribution varies with write rate rather than being fixed.

Configuration Pools Per Process Watch For
Single database 1 Nothing unusual
Primary + replica 2 Both sized as if they were the only one
Primary + 2 replicas 3 Replica pools rarely need the full thread count
8 shards 8 Multiplies with everything else
Shards + replicas 16 Almost always exceeds a shared budget

The shard row is where Rails deployments most often exceed their allowance without anyone having changed a pool value. Sizing shard pools individually, from each shard’s measured traffic, is the only approach that scales — a uniform value across sixteen pools is a uniform overcommitment.

Integration & Proxy Compatibility

Behind a transaction-mode proxy, Rails needs the same audit as any other framework, and it has two specific findings.

prepared_statements defaults to true on the PostgreSQL adapter, which means ActiveRecord uses server-side named prepared statements — and those break under transaction pooling exactly as described in Using Prepared Statements with PgBouncer Transaction Mode. Setting prepared_statements: false in database.yml is the standard fix, and the plan-reuse it costs is usually immaterial for the short statements Rails generates.

Multi-tenant applications using search_path per tenant are the second finding, and the more dangerous one. Setting the search path per connection leaks between tenants under transaction pooling; setting it per transaction with SET LOCAL does not. The variables: block in database.yml applies settings at connection establishment, which is safe only because those values are constant for the process — a per-tenant value must not go there.

Advisory locks deserve a mention because Rails uses them itself: ActiveRecord::Migration takes a session-level advisory lock to serialise concurrent migrations. Under transaction pooling that lock does not behave as intended, which is one reason migrations should run through a direct connection rather than through the proxy.

production:
  adapter: postgresql
  pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
  prepared_statements: false      # required behind transaction-mode pooling
  advisory_locks: false           # migrations should use a direct connection instead
  variables:
    application_name: orders-web  # constant per process — safe here
Pools multiply with database entries Each entry in database.yml is a separate pool per process, so a configuration with a primary, a replica and several shards multiplies the per-process connection count well beyond the single pool value. Per-process connections, pool: 5 in every entry primary only 5 + replica 10 + 2 more replicas 20 8 shards + replicas 80 per process — before workers and replicas are counted Every one of these configurations contains the single line "pool: 5" Size each entry from its own measured traffic; replicas and low-volume shards rarely need the full thread count
Each `database.yml` entry is an independent pool. A sharded, replicated configuration multiplies the per-process count by the number of entries while every entry still reads `pool: 5`.

Common Failure Patterns & Remediation

Symptom Root Cause Exact Fix Validation
ConnectionTimeoutError after 5 s, database idle Pool smaller than Puma thread count Set poolRAILS_MAX_THREADS waiting returns to zero
Sidekiq raises ConnectionTimeoutError, web tier fine Worker inherits a web-sized pool Separate pool value for the worker tier Worker waiting at zero at full concurrency
FATAL: too many clients after a scale-out Replicas × workers × pool exceeds the budget Recompute from surge replica count Backends stay under 80% of the limit
dead count climbing Threads killed while holding connections Investigate request timeouts and OOM kills dead returns to zero
prepared statement does not exist behind a proxy prepared_statements: true under transaction pooling Set it to false Error absent under sustained load
Tenant data appears under the wrong tenant Per-connection search_path under transaction pooling SET LOCAL inside the transaction Probe reports the default path after commit
Connections held through slow rendering Default per-request checkout scope Move slow work out of the request, or accept the sizing Hold-time p99 approaches query time

Frequently Asked Questions

How does Puma’s preload_app! interact with the pool?
It forks workers after loading the application, which means anything that opened a connection during boot is inherited by every worker — the classic pre-fork hazard. Rails handles the common case with Puma.before_fork disconnecting and on_worker_boot re-establishing, and a generated puma.rb includes those hooks. Removing them, or opening a connection from an initializer that runs after them, reintroduces the problem.
Does ActiveRecord::Base.clear_active_connections! help with leaks?
It returns connections held by the current thread, which is useful at the end of a long-running background task that did not use with_connection. It is a cleanup, not a fix: the underlying issue is a thread holding a connection outside the request middleware, and the durable answer is to scope the checkout explicitly.
Should pool ever exceed the thread count?
By one or two, to cover Rails-internal threads and any periodic task in the process. Beyond that it allocates connections no thread can use, and on a shared database those are connections another service needs.
How should the pool differ between the web and worker tiers?
By the concurrency of each. Puma’s thread count governs the web tier; Sidekiq’s concurrency governs the worker tier. Reading the value from a tier-specific environment variable is the standard approach, since both tiers load the same database.yml.
Does ActiveRecord::Base.connection_pool.with_connection change anything?
It makes the checkout explicit and scoped, returning the connection at the end of the block rather than the end of the request. In a background thread — outside Rails’ request middleware — it is the correct way to acquire a connection, and forgetting it is how long-lived threads leak.
Does checkout_timeout interact with Rack timeout middleware?
It should be strictly shorter. A checkout_timeout longer than the request timeout means a thread sits waiting for a connection after the request that needed it has already been abandoned — holding a Puma thread that could be serving something else. Three seconds against a ten-second request timeout is a reasonable pairing.
Is idle_timeout worth setting?
Yes on a shared database. It releases connections a quiet worker is holding, which returns budget to the pool of connections other services can use. The cost is a handshake when the worker becomes busy again, which is negligible against the alternative.
Why does the connection count not drop when traffic does?
Because ActiveRecord keeps connections up to the pool size until idle_timeout reclaims them, and idle_timeout is unset by default. Without it, a process that reached full utilisation once holds that many connections until it restarts.
Does Rails 7.1’s connection pooling differ materially from earlier versions?
The pool mechanics are unchanged; what has improved is the surrounding tooling — better multiple-database ergonomics, clearer role switching, and connection_pool.stat becoming the standard way to inspect state. The sizing rule and the thread relationship are the same as they have been for a decade.
Should the pool differ between a Puma cluster and a single-process deployment?
Only through the multiplier. The per-process value is still driven by the thread count; what changes is how many processes hold that many connections. A single-process deployment with sixteen threads and a pool of seventeen is a perfectly reasonable configuration that a clustered deployment could not afford.