7 Proven Active Record Patterns That Stop Rails Apps From Becoming Slow

Boost Rails performance with 7 proven Active Record query optimization patterns. Fix N+1 queries, master eager loading, use database features effectively. Make your app faster today.

7 Proven Active Record Patterns That Stop Rails Apps From Becoming Slow

When you build applications with Ruby on Rails, you interact with your database using Active Record. It feels like magic. You write clear, Ruby-like code, and it handles the complex SQL for you. But sometimes, that magic can be slow. As your application grows and more data fills your tables, those convenient queries can start to drag your application down. I’ve seen it happen many times—a page that was snappy with a hundred records becomes painfully slow with ten thousand.

The good news is that we can guide this magic. We can write our Active Record queries in ways that are both clean for us and efficient for the database. Over time, I’ve learned a handful of reliable approaches that solve the most common performance problems. I’d like to share seven of these with you. They are not complex theories, but practical patterns you can use today.

Let’s start with a problem so common it has a name: the N+1 query. Imagine you’re listing products, and for each product, you display its category name. You might write a loop like this:

@products = Product.limit(20)
@products.each do |product|
  puts product.category.name
end

This seems fine. But what Active Record does behind the scenes is not one query, but twenty-one. First, it runs one query to get your 20 products. Then, for each of those 20 products, it runs a separate query to find its category. That’s 20 more queries. One initial query plus N (20) more. This is the N+1 problem, and it’s a major cause of slow pages.

The solution is to tell Active Record what you need upfront. Use includes. This is called eager loading. You’re saying, “Go get the products, and while you’re at it, go get their related categories too, all in an efficient way.”

@products = Product.includes(:category).limit(20)

Now, Active Record will make just two queries. One for the products, and a second, smart query to fetch all the related categories for those specific products. When you loop through them, product.category is already loaded into memory. No more waiting for the database on each loop. You can include multiple associations and even nest them.

# Load products with their categories and all the reviews for those products
@products = Product.includes(:category, :reviews).where(active: true)

# You can go deeper. Load users, their orders, and each order's items and payments.
@users = User.includes(orders: [:items, :payments]).where(active: true)

A word of caution: eager loading is powerful, but don’t get carried away. If you’re only showing a product list and never use the review data, don’t include it. You’re just moving more data than you need. I sometimes make includes conditional based on what the page needs.

# Decide what to load based on parameters or context
associations_to_load = [:category]
associations_to_load << :inventory if show_inventory_page?
associations_to_load << { reviews: :user } if detailed_view?

@products = Product.includes(associations_to_load).where(active: true)

Sometimes, you don’t need to load the associated data, you just need to filter by it. For example, you want all products that belong to an “Electronics” category, but you don’t need to show the category name on the list. This is where joins comes in. It creates a SQL JOIN to link the tables for filtering or sorting, but it doesn’t pull the category data into your product objects.

# Find all products in the 'Electronics' category
@products = Product.joins(:category)
                  .where(categories: { name: 'Electronics' })
                  .where('products.price > ?', 100)

You can join multiple associations to create complex filters. Let’s say you need orders from users in the US for products in a specific category.

@orders = Order.joins(user: :address, items: :product)
              .where(addresses: { country: 'US' })
              .where(products: { category_id: 5 })
              .distinct

Notice the .distinct at the end? That’s important. If one order has multiple items that match the product condition, the join could cause that same order to appear multiple times in your results. distinct ensures you get a unique list.

Joins are also perfect for calculations. You can ask the database to do the math, which is almost always faster than pulling data into Ruby and calculating there.

# Get users and their order count in one query
@users = User.left_joins(:orders)
            .select('users.*, COUNT(orders.id) as order_count')
            .group('users.id')

As your queries get more complex, your code can become messy and repetitive. You might find the same where(active: true) clause scattered across dozens of controllers. This is where scopes shine. A scope is a predefined query fragment you define in your model. It makes your intentions clear and your code reusable.

class Product < ApplicationRecord
  scope :active, -> { where(active: true) }
  scope :available, -> { where('inventory_count > 0') }
  scope :priced_between, ->(min, max) { where(price: min..max) }

  # Scopes can be combined!
  scope :featured_and_available, -> { active.available.where(featured: true) }

  # They can use joins too
  scope :from_category, ->(category_name) do
    joins(:category).where(categories: { name: category_name })
  end
end

Now, in your controller or console, your queries read like sentences.

@products = Product.featured_and_available
                  .from_category('Electronics')
                  .priced_between(50, 500)
                  .order(created_at: :desc)

This is much easier to read, write, and test. If the logic for what “available” means changes, you update it in one place.

Here’s another classic slowdown. On a product page, you want to show “Number of Reviews: 42”. You might think to use @product.reviews.count. Active Record will run a COUNT(*) query on the reviews table. For a single page, that’s okay. But if you list 100 products on a page and show the review count for each, you’ve just triggered 100 extra count queries. Ouch.

We can fix this with a counter cache. It’s a special integer column on the parent table that automatically keeps track of how many children it has.

First, you add the column via a migration.

class AddReviewsCountToProducts < ActiveRecord::Migration[7.0]
  def change
    add_column :products, :reviews_count, :integer, default: 0, null: false

    # This updates the count for all existing products
    Product.find_each do |p|
      Product.reset_counters(p.id, :reviews)
    end
  end
end

Then, you tell the child model to use it.

class Review < ApplicationRecord
  belongs_to :product, counter_cache: true
end

Now, every time a review is created or destroyed, Rails automatically increments or decrements the reviews_count on the associated product. When you want the count, you just read the column value. No query needed.

@product.reviews_count # This is just reading an attribute!
@popular_products = Product.order(reviews_count: :desc).limit(10)

This pattern is perfect for counts you display frequently. It trades a tiny bit of extra write overhead for a massive read performance gain.

What do you do when you need to process every record in a huge table? Maybe you need to update a price for all ten million products. Your first instinct, Product.all.each, would try to load ten million objects into memory at once. Your server will not be happy.

The tool for this job is find_in_batches. It fetches records in manageable groups, processes them, and moves on, keeping memory usage low.

Product.find_in_batches(batch_size: 1000) do |batch_of_products|
  batch_of_products.each do |product|
    product.update(price: product.price * 1.05) # 5% price increase
  end
end

You can add conditions and ordering. This is useful for batch jobs that run in the background.

# Archive old records in batches
Product.where('created_at < ?', 1.year.ago)
      .order(created_at: :asc)
      .find_in_batches do |batch|
  batch.each(&:archive!)
end

A pro tip: If your batch job might be interrupted (say, a server restart), you can make it resumable by tracking the last processed ID.

last_processed_id = get_checkpoint_from_disk || 0

Product.where('id > ?', last_processed_id)
      .find_in_batches(batch_size: 500) do |batch|
  process_the_batch(batch)
  new_checkpoint = batch.last.id
  save_checkpoint_to_disk(new_checkpoint)
end

Often, you don’t need every piece of data from a table. If you’re building a dropdown list of product names and IDs, you don’t care about the description, weight, or inventory count. Loading all those columns is wasteful. Use select to choose only what you need.

# Just get the id, name, and price
@product_list = Product.select(:id, :name, :price).limit(50)

For even simpler cases, where you don’t need Active Record objects at all—just raw values—use pluck. It gets data straight from the database and returns simple arrays or hashes. It’s very fast.

# Get an array of just the product IDs
ids = Product.where(category_id: 3).pluck(:id)
# => [1, 5, 23, 42]

# Get an array of [name, price] pairs
name_price_pairs = Product.active.pluck(:name, :price)
# => [["Widget", 19.99], ["Gadget", 49.99]]

# You can even build a quick lookup hash
product_names_by_id = Product.pluck(:id, :name).to_h
# => {1=>"Widget", 5=>"Gadget"}

And when you want just one single value, like the most recent order date, use pick or aggregation methods like maximum.

latest_date = Order.maximum(:created_at)
active_user_count = User.active.count

Finally, don’t forget that your database (PostgreSQL, MySQL, SQLite) is incredibly powerful. Active Record provides a common interface, but sometimes you need to speak the database’s native dialect for optimal performance. You can drop down to SQL fragments for advanced features.

For complex reporting queries, a Common Table Expression (CTE) in PostgreSQL can make your logic clean and fast.

# Let's find products priced above their category's average
average_price_query = Product.select('category_id, AVG(price) as avg_price')
                             .group(:category_id)

@products = Product.with(average_prices: average_price_query)
                  .joins('JOIN average_prices ON products.category_id = average_prices.category_id')
                  .where('products.price > average_prices.avg_price')

If you need to rank items within groups, window functions are your friend (available in PostgreSQL and modern SQLite).

# Find the top 3 most expensive products in each category
@products = Product.select(
  '*, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY price DESC) as category_price_rank'
).from('products_with_rank')
 .where('category_price_rank <= 3')

These database-specific features are advanced tools. Use them when you’ve hit the limits of the standard Active Record interface and you’re sure they will help. The key is to always measure and profile your queries to see what’s actually slow.

In my work, applying these seven patterns—eager loading with includes, filtering with joins, organizing with scopes, caching counts, processing in batches, selecting only necessary data, and tapping into database power—solves the vast majority of query performance issues I encounter. They help keep the magic of Active Record from becoming a burden. Start with the basics like fixing N+1 queries, and gradually incorporate the others as your data grows and your needs become more complex. Your database, and your users, will thank you.


// Keep Reading

Similar Articles