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’sthreadsmaximum, 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 indatabase.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::ConnectionTimeoutErroraftercheckout_timeoutseconds 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.
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.
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
Common Failure Patterns & Remediation
| Symptom | Root Cause | Exact Fix | Validation |
|---|---|---|---|
ConnectionTimeoutError after 5 s, database idle |
Pool smaller than Puma thread count | Set pool ≥ RAILS_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?
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?
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?
How should the pool differ between the web and worker tiers?
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?
Does checkout_timeout interact with Rack timeout middleware?
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?
Why does the connection count not drop when traffic does?
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?
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?
Related
- Framework Integration & Connection Lifecycle — the parent reference on checkout scope and per-framework configuration.
- Fixing ActiveRecord ConnectionTimeoutError in Puma — incident procedure for the signature Rails failure.
- Configuring ActiveRecord with PgBouncer Transaction Mode — the settings a Rails application needs behind a multiplexing proxy.
- Transaction vs Statement Pooling Trade-offs — what session state costs behind a proxy, and how to audit for it.
- Connection Pool Sizing Formulas — the budget arithmetic that bounds the product of replicas, workers and pool.