When your Rails application starts to grow, you’ll often find the database is the first thing that starts to slow down. It’s a common story. One day, your queries are fast, and everything feels smooth. The next, as more users and data pour in, pages begin to stutter. The default way Rails handles databases is brilliant for getting started, but it can hit a wall when you’re dealing with millions of records or thousands of requests per second.
I want to talk about how we can design our database from the ground up to handle that growth. This isn’t about quick fixes; it’s about changing how we think about storing and accessing data as our application expands.
Let’s start with a powerful concept: splitting large tables into smaller, physical pieces, while your application still sees it as one table. Think of it like a filing cabinet for each month of the year. You know exactly where to look for a document from July, so you don’t have to search through the entire year’s paperwork. This is table partitioning.
There are a few smart ways to do this. If your data is tied to time, like orders or log entries, range partitioning is your friend. You can store each month’s data in its own separate table behind the scenes.
# Let's partition orders by the month they were created
class CreateOrders < ActiveRecord::Migration[7.0]
def change
create_table :orders, id: false do |t|
t.bigint :id, primary_key: true
t.decimal :total_amount
t.datetime :created_at
t.bigint :customer_id
t.string :status
end
# Create a separate table for each month
(2023..2025).each do |year|
1.upto(12) do |month|
start_date = Date.new(year, month, 1)
end_date = start_date.next_month
execute <<-SQL
CREATE TABLE orders_#{year}_#{month.to_s.rjust(2, '0')}
PARTITION OF orders
FOR VALUES FROM ('#{start_date}') TO ('#{end_date}')
SQL
end
end
end
end
When you query for recent orders, the database only looks at the latest partition. An archive query for old data scans a different, smaller table. Maintenance tasks like index rebuilding or backups can also target individual partitions, which is much faster.
What if your data isn’t time-based, but falls into clear categories? List partitioning works well here. Imagine an e-commerce site with distinct product types.
class CreateProducts < ActiveRecord::Migration[7.0]
def change
create_table :products, id: false do |t|
t.bigint :id, primary_key: true
t.string :name
t.string :category
t.decimal :price
t.timestamps
end
# Store electronics, clothing, books in their own tables
%w[electronics clothing books home].each do |category|
execute <<-SQL
CREATE TABLE products_#{category}
PARTITION OF products
FOR VALUES IN ('#{category}')
SQL
end
end
end
Now, a query for all 'electronics' only searches the products_electronics partition. Sometimes, you just need to spread data evenly, like for user IDs or event logs. Hash partitioning calculates which partition to use based on a mathematical hash of a column’s value.
class CreateEvents < ActiveRecord::Migration[7.0]
def change
create_table :events, id: false do |t|
t.bigint :id, primary_key: true
t.string :event_type
t.jsonb :payload
t.timestamps
end
# Distribute rows evenly across 8 partitions
8.times do |partition|
execute <<-SQL
CREATE TABLE events_part#{partition}
PARTITION OF events
FOR VALUES WITH (MODULUS 8, REMAINDER #{partition})
SQL
end
end
end
Partitioning helps a lot, but it still keeps all your data on one database server. What happens when a single server can’t handle the load, no matter how well you partition? You need to go wider. You need to share the load across multiple, independent database instances. This is sharding.
Sharding is more complex because your application needs to know where to find each piece of data. I’ve implemented this by creating a connection manager that switches between database connections based on where the data lives.
class ShardConnection
# Define our different database servers
SHARDS = {
shard1: { host: 'db1.example.com', database: 'app_shard1' },
shard2: { host: 'db2.example.com', database: 'app_shard2' },
shard3: { host: 'db3.example.com', database: 'app_shard3' }
}.freeze
def self.for_shard(shard_name)
config = SHARDS[shard_name.to_sym]
ActiveRecord::Base.establish_connection(config)
end
def self.with_shard(shard_name, &block)
# Remember the current shard
previous_shard = Thread.current[:current_shard]
# Switch to the new one
Thread.current[:current_shard] = shard_name
for_shard(shard_name)
result = block.call
# Switch back when done
for_shard(previous_shard) if previous_shard
result
ensure
Thread.current[:current_shard] = previous_shard
end
end
The key is deciding how to split your data across shards. A common way is to take a piece of data, like a customer_id, hash it, and use the result to pick a shard. This spreads customers evenly.
class User < ApplicationRecord
SHARD_KEY = :customer_id
def self.on_shard(shard_name, &block)
ShardConnection.with_shard(shard_name) do
if block_given?
block.call
else
all
end
end
end
def shard_name
# Figure out which shard this user's data is on
shard_for_attribute(self[SHARD_KEY])
end
def shard_for_attribute(value)
hash = Digest::MD5.hexdigest(value.to_s).to_i(16)
shard_count = ShardConnection::SHARDS.size
"shard#{(hash % shard_count) + 1}"
end
end
# You can then query a user on their specific shard
User.find(123).on_its_shard do
@orders = Order.where(user_id: 123).limit(10)
end
To make this seamless for web requests, you can use a piece of middleware that examines each incoming request and routes it to the correct shard, perhaps based on a customer ID in a header or a subdomain.
Sharding is powerful, but it adds significant complexity for queries that need to span multiple shards. It’s a trade-off you accept for the ability to scale horizontally.
Often, the bottleneck isn’t just where data is stored, but how many steps it takes to get it. A dashboard that shows sales summaries might need to join five huge tables. Doing that join on the fly for every page load is a recipe for slow performance. This is where we sometimes bend the classic database rules and duplicate data to save time. This is denormalization.
One of my favorite tools for this is a materialized view. It’s like a snapshot of a complex query result, stored as a real table that you can query instantly.
class ProductSalesSummary < ActiveRecord::Base
self.table_name = 'product_sales_summary'
def self.refresh(concurrently: true)
# Repopulate the snapshot with fresh data
sql = "REFRESH MATERIALIZED VIEW"
sql += " CONCURRENTLY" if concurrently # Doesn't block reads
sql += " product_sales_summary"
ActiveRecord::Base.connection.execute(sql)
end
def self.create_view
sql = <<-SQL
CREATE MATERIALIZED VIEW product_sales_summary AS
SELECT
p.id as product_id,
p.name as product_name,
-- ... many aggregations and joins ...
SUM(oli.quantity * oli.unit_price) as total_revenue
FROM products p
JOIN order_line_items oli ON p.id = oli.product_id
JOIN orders o ON oli.order_id = o.id
JOIN categories c ON p.category_id = c.id
WHERE o.status = 'completed'
GROUP BY p.id, p.name, p.category_id, c.name
SQL
ActiveRecord::Base.connection.execute(sql)
end
end
Your dashboard can now query ProductSalesSummary.all in milliseconds. You just need a background job to periodically call ProductSalesSummary.refresh.
You can also add simple denormalized columns. Storing a total_comments_count directly on a Post record is much faster than counting related Comment records every time. The trick is keeping that count accurate. You can update it in an after_save callback on the Comment model, or use database triggers.
class CreateOrderDenormalizationTriggers < ActiveRecord::Migration[7.0]
def up
# This database function runs automatically
execute <<-SQL
CREATE OR REPLACE FUNCTION update_customer_order_summary()
RETURNS TRIGGER AS $$
BEGIN
UPDATE customers
SET
total_orders = (SELECT COUNT(*) FROM orders WHERE customer_id = NEW.customer_id),
total_spent = (SELECT COALESCE(SUM(total_amount), 0) FROM orders WHERE customer_id = NEW.customer_id),
last_order_at = NOW()
WHERE id = NEW.customer_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Attach the function to run after every order insert/update
CREATE TRIGGER orders_denormalize_customer
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION update_customer_order_summary();
SQL
end
end
The database itself maintains the consistency, which is often more reliable than application code.
Of course, none of this works well without the right signposts. Indexes are those signposts. But creating random indexes isn’t helpful. You need to design them for how your application actually queries data.
A query for “all active orders for customer 456, sorted newest first” needs a different index than a query for “orders pending shipment.” You can create indexes that match these exact patterns.
class AddOrderQueryIndexes < ActiveRecord::Migration[7.0]
def change
# Perfect for loading a customer's order history page
add_index :orders, [:customer_id, :created_at],
order: { created_at: :desc }
# Efficient for finding orders that need work
add_index :orders, [:status, :created_at],
where: "status IN ('pending', 'processing')"
# An index that includes all needed data, so the DB doesn't visit the main table
add_index :orders, [:customer_id, :status, :created_at],
include: [:total_amount, :currency]
end
end
For time-series data that is mostly written once and read in date order, like logs, a BRIN (Block Range INdex) can be a huge space saver with great performance.
You should also regularly check if your indexes are still useful. It’s easy for old indexes to linger long after the queries they helped have changed.
def identify_unused_indexes
# Query PostgreSQL's statistics
query = <<-SQL
SELECT indexname, idx_scan as index_scans
FROM pg_stat_user_indexes
WHERE tablename = 'orders'
SQL
results = ActiveRecord::Base.connection.execute(query).to_a
results.select { |idx| idx['index_scans'].to_i == 0 }
end
As time goes on, a lot of your data becomes historical. Old completed orders, year-old log messages. This data is important, but querying it slows down your work on today’s data. You need a plan to move it aside.
A straightforward method is to archive records older than a certain date. Copy them to an archived_orders table, then delete them from the main orders table. You can run this in batches to avoid locking your table for too long.
def archive_old_orders
cutoff_date = 2.years.ago
Order.where('created_at < ?', cutoff_date)
.find_in_batches(batch_size: 10000) do |batch|
# Insert all batch records into the archive table at once
archive_data = batch.map { |o| {original_id: o.id, data: o.as_json} }
ArchivedOrder.insert_all(archive_data)
# Then delete the originals
Order.where(id: batch.map(&:id)).delete_all
end
end
If you’re already using partitioning, archiving becomes elegant. You can “detach” an old partition (like orders_2022_01) from the main table. This makes it instantly invisible to your application. You can then compress it, move it to cheaper storage, or back it up and delete it.
All these techniques—splitting data, copying data, moving data—introduce a new challenge: keeping everything correct. When you update a customer’s primary email in one shard, does their profile on another shard reflect that? When a background job recalculates a denormalized total, what happens if a new order comes in mid-calculation?
For critical operations that update multiple resources, you might need a two-phase commit process. It’s a way for all participants to agree “yes, we’re ready to make this change” before anything is permanently changed.
More often in distributed systems, you accept that things might be temporarily out of sync and will converge on the correct state later. This is eventual consistency. You might store a local version of a user’s preferences on your web server for speed, and sync it with a central database every few minutes. If there’s a conflict—maybe they updated preferences from their phone at the same time—you need rules to decide which change wins.
I’ve found it essential to build routines that regularly scan for these inconsistencies and fix them.
class DataConsistencyChecker
def check_denormalized_fields(model)
issues = []
model.denormalized_fields.each do |field|
# Calculate what the value *should* be from raw data
actual = calculate_actual_value(model, field)
# Read what is *stored* in the denormalized column
stored = model.average(field)
if (actual - stored).abs > 0.01
issues << { field: field, actual: actual, stored: stored }
end
end
issues
end
def repair_issues(issues)
issues.each do |issue|
# Update the denormalized column with the correct value
issue.model.update_column(issue.field, issue.actual)
end
end
end
Running a job like this nightly acts as a safety net, correcting small drifts before they become big problems.
Scaling a database isn’t about finding one magical solution. It’s about understanding a set of tools and patterns. Partitioning organizes data within a server. Sharding spreads data across servers. Denormalization trades storage space and write complexity for read speed. Intelligent indexing makes sure the database can find data efficiently. Archiving keeps active tables lean. Consistency mechanisms ensure all these moving parts still tell the truth.
You won’t need all of these on day one. Start simple. Add a strategic index. Cache an expensive query. Introduce partitioning when a table gets large. Consider sharding when you’re pushing the limits of a single machine. Each step adds complexity, so take it when the performance benefit is clear and necessary. The goal is to build a foundation that can grow gracefully with your application’s success.