Rails Database Connection Pooling Explained
Every Rails application that handles multiple concurrent requests needs an efficient way to manage database connections. Without proper connection management, your app could create a new database connection for every request, quickly overwhelming your database server. This is where connection pooling comes in.
The Problem: Why Connection Pooling Matters
Creating database connections is expensive. Each new connection requires:
- Network handshake between app and database
- Authentication verification
- Memory allocation on both client and server
- Connection state initialization
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Without connection pooling (this is what would happen internally)
def handle_request
# This takes 20-50ms each time!
connection = PG.connect(
host: 'localhost',
dbname: 'myapp_production',
user: 'rails',
password: 'secret'
)
result = connection.exec("SELECT * FROM users WHERE id = 1")
connection.close
result
end
For a busy application handling 100 requests per second, creating fresh connections would add 2-5 seconds of overhead every second – clearly unsustainable.
How Rails Implements Connection Pooling
Rails solves this through ActiveRecord
’s built-in connection pool. Instead of creating new connections for each request, Rails maintains a pool of reusable connections.
Important: Each Rails process maintains its own independent connection pool. If you’re running 5 Puma workers, you’ll have 5 separate pools, not one shared pool.
1
2
3
4
5
6
# config/database.yml
production:
adapter: postgresql
database: myapp_production
pool: 5 # Maximum number of connections in the pool
timeout: 5000 # Wait up to 5 seconds for a connection
When a request needs a database connection:
1
2
3
4
5
6
7
class UsersController < ApplicationController
def show
# ActiveRecord automatically checks out a connection from the pool
@user = User.find(params[:id])
# Connection is automatically returned to the pool after the request
end
end
Here’s what happens behind the scenes:
1
2
3
4
5
6
7
# Simplified version of what ActiveRecord does
def with_connection(&block)
connection = connection_pool.checkout # Get connection from pool
yield connection # Use it for queries
ensure
connection_pool.checkin(connection) # Return to pool
end
Understanding Checkout and Checkin:
-
Checkout: When a thread needs to run a query, it “checks out” a connection from the pool, like borrowing a book from a library. The connection is marked as “in use” and becomes unavailable to other threads.
-
Checkin: After the thread finishes its database work, it “checks in” the connection back to the pool, making it available for other threads to use. The connection stays open to the database but is now free to be borrowed again.
This borrow-and-return cycle happens automatically for every ActiveRecord
query, ensuring connections are efficiently shared between requests without the overhead of creating new ones.
Note: Rails 7.2 fundamentally changed how connections are managed. Instead of holding a connection for an entire request, Rails now automatically returns connections to the pool after each individual query executes. This means a single request might use different connections for different queries, significantly improving connection utilization in high-concurrency applications.
Understanding Pool Configuration
The pool
setting in database.yml
controls the maximum number of connections your app can maintain:
1
2
3
4
5
6
# config/database.yml
production:
adapter: postgresql
database: myapp_production
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
Key configuration options:
pool
: Maximum connections (default: 5)timeout
: Milliseconds to wait for a connection (default: 5000)checkout_timeout
: Alias for timeoutidle_timeout
: Seconds before closing idle connections (default: 300)reaping_frequency
: Seconds between reaping dead connections (default: 60)
Rails automatically runs a reaper thread that periodically removes connections that are dead or have been idle too long. This prevents your pool from filling up with unusable connections and helps maintain optimal resource usage.
Common Connection Pool Issues
1. Pool Exhaustion
The most common issue is running out of connections:
1
2
# This error means all connections are in use
ActiveRecord::ConnectionTimeoutError: could not obtain a connection from the pool within 5.000 seconds
This happens when:
- Your pool size is smaller than your thread count
- Long-running queries hold connections
- Connections leak due to improper handling
Debugging pool exhaustion:
1
2
3
4
5
6
7
8
9
10
11
12
# Check current pool status
ActiveRecord::Base.connection_pool.stat
# => { size: 5, connections: 5, busy: 5, dead: 0, idle: 0, waiting: 2, checkout_timeout: 5 }
# See all connections and what they're doing
ActiveRecord::Base.connection_pool.connections.map do |conn|
{
in_use: conn.in_use?,
owner: conn.owner,
last_query: conn.instance_variable_get(:@last_query)
}
end
⏳ waiting"] T4["Thread 4
⏳ waiting"] T3 -.-> T4 end subgraph Pool["Connection Pool (Size: 5)"] direction TB subgraph Busy[" "] direction LR C1["Connection 1
🔴 BUSY
(Thread 1)"] C2["Connection 2
🔴 BUSY
(Thread 2)"] end subgraph Available[" "] direction LR C3["Connection 3
🟢 IDLE"] C4["Connection 4
⚫ DEAD"] C5["Connection 5
🟢 IDLE"] end end Queue -->|Waiting for
available connection| Pool classDef busy fill:#ffcccc,stroke:#ff0000,stroke-width:2px classDef idle fill:#ccffcc,stroke:#00aa00,stroke-width:2px classDef dead fill:#e0e0e0,stroke:#666666,stroke-width:2px classDef waiting fill:#ffffcc,stroke:#ffaa00,stroke-width:2px classDef poolStyle fill:#f9f9f9,stroke:#333,stroke-width:2px class C1,C2 busy class C3,C5 idle class C4 dead class T3,T4 waiting class Pool poolStyle
2. Thread Count Mismatch
Your pool size must accommodate your web server’s thread configuration:
1
2
3
4
5
6
7
# config/puma.rb
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count
# config/database.yml
production:
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> # Should match!
If threads > pool size, some threads may timeout waiting for connections.
🔴 Thread 1"] C2["Connection 2
🔴 Thread 2"] C3["Connection 3
🔴 Thread 3"] end subgraph Problem["Problem"] E1["Thread 4 & 5 waiting...
Will timeout after 5 seconds
❌ ConnectionTimeoutError"] end T1 --> C1 T2 --> C2 T3 --> C3 T4 -.->|"Can't get connection"| Problem T5 -.->|"Can't get connection"| Problem classDef connected fill:#ccffcc,stroke:#00aa00,stroke-width:2px classDef waiting fill:#ffffcc,stroke:#ffaa00,stroke-width:2px classDef error fill:#ffcccc,stroke:#ff0000,stroke-width:2px classDef poolWarn fill:#fff3cd,stroke:#ff9800,stroke-width:3px class T1,T2,T3 connected class T4,T5 waiting class Problem error class Pool poolWarn
The fix: Ensure your pool size matches or exceeds your thread count.
3. Connection Leaks
Manually checking out connections without returning them causes leaks:
1
2
3
4
5
6
7
8
9
10
11
12
13
# BAD: Connection leak!
def process_large_dataset
conn = ActiveRecord::Base.connection_pool.checkout
conn.execute("SELECT * FROM huge_table")
# Forgot to check in the connection!
end
# GOOD: Proper connection handling
def process_large_dataset
ActiveRecord::Base.connection_pool.with_connection do |conn|
conn.execute("SELECT * FROM huge_table")
end # Connection automatically returned
end
For more details on proper connection management patterns, see our post on Rails with_connection: The better way to manage database connections.
Advanced Connection Pool Management
Multiple Database Connections
Rails 6+ supports multiple databases with separate pools:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# config/database.yml
production:
primary:
adapter: postgresql
database: myapp_production
pool: 25
analytics:
adapter: postgresql
database: myapp_analytics
pool: 10
# app/models/analytics_base.rb
class AnalyticsBase < ApplicationRecord
self.abstract_class = true
connects_to database: { writing: :analytics, reading: :analytics }
end
Why separate pools matter here:
With a single pool, long-running analytics queries would check out connections for extended periods, starving your web requests. By using separate pools:
- Your main app maintains 25 connections for quick user requests
- Analytics gets its own 10 connections that can be held longer without impacting users
- Connection timeouts can be configured differently (5 seconds for web, 30 seconds for analytics)
- If analytics exhausts its pool, your main application continues serving users normally
This isolation prevents one workload from monopolizing all available connections.
25 connections
Quick transactions"] AnalyticsPool["Analytics Pool
10 connections
Long queries OK"] end subgraph DBs["Databases"] PrimaryDB[("Primary DB
Users, Products, Orders")] AnalyticsDB[("Analytics DB
Reports, Metrics")] end UserModel --> PrimaryPool AnalyticsModel --> AnalyticsPool PrimaryPool --> PrimaryDB AnalyticsPool --> AnalyticsDB style PrimaryPool fill:#e1f5fe style AnalyticsPool fill:#fce4ec style PrimaryDB fill:#0277bd,color:#fff style AnalyticsDB fill:#c2185b,color:#fff
Dynamic Pool Adjustment
You can modify pool settings at runtime:
1
2
3
4
5
6
7
8
# Increase pool size for background jobs
if Sidekiq.server?
ActiveRecord::Base.connection_pool.disconnect!
config = ActiveRecord::Base.connection_config
config[:pool] = 25
ActiveRecord::Base.establish_connection(config)
end
The pool sizing challenge this solves:
Web servers typically need 1 connection per thread (5 threads = 5 connections). But background job processors like Sidekiq run many concurrent jobs:
- Sidekiq with 25 concurrency needs 25 connections
- Using a pool of 5 would cause 20 jobs to wait for connections
- Jobs would timeout and fail with
ConnectionTimeoutError
By dynamically increasing the pool size to 25 for Sidekiq processes, each job gets its own connection. This prevents job failures and ensures background work doesn’t compete with web traffic for the same limited pool.
Connection Pool Middleware
Create middleware to monitor connection usage:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# app/middleware/connection_pool_monitor.rb
class ConnectionPoolMonitor
def initialize(app)
@app = app
end
def call(env)
pool = ActiveRecord::Base.connection_pool
Rails.logger.info "Pool before request: #{pool.stat}"
@app.call(env)
ensure
Rails.logger.info "Pool after request: #{pool.stat}"
# Alert if pool is nearly exhausted
if pool.stat[:busy] > pool.size * 0.8
Rails.logger.warn "Connection pool usage high: #{pool.stat}"
end
end
end
How this helps manage your pool:
Without visibility, connection pool issues appear as random timeouts. This middleware reveals:
- Pool efficiency: If
busy: 5, idle: 0
constantly, your pool is too small - Leak detection: If
busy
count grows but never decreases, connections aren’t being returned - Hotspot identification: Specific endpoints consistently pushing pool to 80%+ usage
- Sizing validation: If
waiting: 0
always, you might have an oversized pool wasting resources
Real example output showing a pool under stress:
1
2
3
Pool before request: { size: 5, connections: 5, busy: 4, dead: 0, idle: 1, waiting: 0 }
Pool after request: { size: 5, connections: 5, busy: 5, dead: 0, idle: 0, waiting: 2 }
WARNING: Connection pool usage high!
This data proves you need to increase pool size from 5 to at least 7.
Optimizing Connection Pool Performance
1. Right-size Your Pool
1
2
3
4
5
# Calculate optimal pool size per process
optimal_pool_size = web_server_threads + background_job_threads + buffer
# For Puma with 5 threads and Sidekiq with 10 threads:
# pool: 5 + 10 + 5 = 20
Calculating total database connections:
Your actual database connection count depends on all running processes:
1
2
3
4
5
6
7
# Total connections = Sum of all process pools
web_connections = puma_workers * threads_per_worker * pool_size
sidekiq_connections = sidekiq_processes * concurrency * pool_size
# Example: 4 Puma workers with 5 threads each, pool size 5
# Plus 2 Sidekiq processes with 25 concurrency, pool size 25
total = (4 * 5) + (2 * 25) = 70 connections
Ensure your database can handle this total connection count.
2. Use Read Replicas
Distribute load across multiple databases:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# config/database.yml
production:
primary:
adapter: postgresql
database: myapp_production
pool: 15
primary_replica:
adapter: postgresql
database: myapp_production
host: replica.example.com
pool: 10
replica: true
# Queries automatically use replica for reads
User.where(active: true).to_a # Uses replica
User.create!(name: "New") # Uses primary
For a comprehensive guide on implementing read replicas, see our series starting with Scaling Rails with PostgreSQL Read Replicas: Part 1 - Understanding the Basics.
3. Monitor and Alert
Set up monitoring for connection pool metrics:
1
2
3
4
5
6
7
8
9
# config/initializers/connection_pool_instrumentation.rb
ActiveSupport::Notifications.subscribe "!connection.active_record" do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
if event.payload[:connection_id]
StatsD.gauge('db.connection_pool.active',
ActiveRecord::Base.connection_pool.stat[:busy])
end
end
Testing Connection Pool Behavior
Write tests to verify your pool configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# spec/connection_pool_spec.rb
RSpec.describe "Connection Pool" do
it "handles concurrent requests without exhaustion" do
threads = []
errors = []
20.times do
threads << Thread.new do
begin
User.connection_pool.with_connection do
User.count
sleep 0.1 # Simulate work
end
rescue ActiveRecord::ConnectionTimeoutError => e
errors << e
end
end
end
threads.each(&:join)
expect(errors).to be_empty
expect(User.connection_pool.stat[:waiting]).to eq(0)
end
end
When to Adjust Connection Pooling
Increase pool size when:
- Seeing
ConnectionTimeoutError
in logs - Running more threads/workers
- Database can handle more connections
Decrease pool size when:
- Database hitting connection limits
- Memory usage is too high
- Most connections sit idle
Key indicators to monitor:
- Connection wait time
- Pool utilization percentage
- Database connection count
- Query queue length
Connection pooling is fundamental to Rails application performance. By understanding how it works and monitoring its behavior, you can ensure your application handles load efficiently without overwhelming your database.
References