• Home
  • About
  • Portfolio
  • Contact
CodeCurious
  • Home
  • About
  • Portfolio
  • Contact
Go Back

How To Optimize Active Record Queries In Rails

Learn how to optimize Active Record queries in Rails with practical techniques to improve database performance and speed.

Jean Emmanuel Cadet
By Jean Emmanuel Cadet • Ruby on Rails Developer

Last updated : Jul 24, 2026 • 20 min read

How to Optimize Active Record Queries in Rails

Last updated : Jul 24, 2026 • 20 min read

Share with friends

If you have ever watched a Rails application slow to a crawl as your database grows, you already know that queries are usually the first place to look. Active Record makes it incredibly easy to talk to your database using plain Ruby, but that convenience comes with a catch. It can just as easily generate inefficient SQL as it can generate fast SQL, and the difference often comes down to a handful of habits.

Active Record is a powerful ORM. It lets you write User.where(active: true) instead of hand rolling SQL strings, and it handles a huge amount of complexity behind the scenes. But that abstraction can hide what is actually happening at the database level, and that is exactly where performance problems tend to sneak in. A single missing includes, an unnecessary SELECT *, or a loop that triggers hundreds of queries can turn a snappy page into one that takes seconds to load.

In this guide, you will learn how to recognize slow queries, understand the SQL that Active Record generates, and apply practical, production-ready techniques to make your Rails application faster. We will cover N+1 queries, eager loading, indexing, caching, and a full case study where we optimize a blog application step by step.


Why Query Optimization Matters

It is tempting to treat database performance as something you will "deal with later," but in most Rails applications, the database is the bottleneck long before the application server is.

Database latency. Every query travels to the database, executes, and returns a result. Even a fast query on a well indexed table takes time, and that adds up when a single page triggers dozens of queries.

Memory usage. Loading full Active Record objects when you only need a couple of columns wastes RAM, and on a busy server this can trigger garbage collection pauses or out of memory errors.

CPU usage. Instantiating Active Record objects is not free. Ruby allocates objects, runs callbacks, and builds attribute methods for every record. Fetching more data than needed burns more CPU cycles.

User experience. Slow queries translate directly into slow page loads. Users notice delays of a few hundred milliseconds, and search engines factor page speed into rankings.

Scalability. An application that performs fine with a hundred rows can fall apart with a hundred thousand if the underlying queries were never optimized.

Real-world performance impact. It is common to see page load times drop from multiple seconds to under a hundred milliseconds simply by fixing N+1 queries and adding a few indexes.


Understanding How Active Record Generates SQL

Before you can optimize a query, it helps to understand what Active Record is doing behind the scenes. Every Active Record method call builds up a SQL query, and Rails only sends that query to the database once you actually need the data, a concept known as lazy loading.

Consider this simple example:

articles = Article.where(published: true).order(created_at: :desc).limit(5)

At this point, no SQL has run yet. Active Record is just building a query object. The SQL only executes when you do something that forces evaluation, like calling .to_a, iterating with .each, or rendering it in a view.

The generated SQL for the query above looks like this:

SELECT "articles".* FROM "articles"
WHERE "articles"."published" = TRUE
ORDER BY "articles"."created_at" DESC
LIMIT 5

Notice that SELECT "articles".* pulls back every single column, even if your view only displays the title and the publish date. That is often the first place to look when you are trying to reduce the amount of data your application is fetching.

Understanding the SQL that Active Record produces lets you reason about performance instead of guessing, and it makes inefficient patterns much easier to spot. You can always see the SQL for any query by calling .to_sql on it, which is a great habit to build into your workflow.

Article.where(published: true).to_sql
# => SELECT "articles".* FROM "articles" WHERE "articles"."published" = TRUE

Detecting Slow Queries

You cannot optimize what you cannot see. Rails and the broader ecosystem give you several tools for finding slow or excessive queries before they become a problem in production.

Rails logs. In development, your Rails log shows every SQL query along with how long it took to run. Watching your logs while clicking through your application is one of the simplest ways to spot N+1 queries and slow lookups.

Article Load (12.4ms)  SELECT "articles".* FROM "articles" WHERE "articles"."id" = 42
Comment Load (0.6ms) SELECT "comments".* FROM "comments" WHERE "comments"."article_id" = 42

Bullet. The Bullet gem watches your application in development and alerts you, either in the browser, the console, or the log, whenever it detects an N+1 query or unused eager loading. It is one of the highest value gems you can add to a Rails project because it catches these issues before code review.

Rack Mini Profiler. This gem adds a small performance badge to every page in development, showing you total request time and a breakdown of SQL queries. It is especially useful for catching pages that trigger an unexpectedly high number of queries.

EXPLAIN and EXPLAIN ANALYZE. These SQL commands show you how the database plans to execute a query, including whether it is using an index or performing a full table scan. Active Record exposes this directly.

Article.where(category_id: 3).explain

EXPLAIN shows the query plan without running the query, while EXPLAIN ANALYZE actually executes it and reports real timing information. Both are invaluable when you suspect a query is slow because of a missing index.

ActiveSupport Notifications. For deeper instrumentation, Rails exposes a sql.active_record notification that fires on every query. You can subscribe to it to build custom logging or monitoring.

ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Rails.logger.info "[SQL] #{event.duration.round(1)}ms #{event.payload[:sql]}"
end

Using a combination of these tools during development, and lightweight monitoring in production, gives you real visibility into how your queries behave.


Avoid N+1 Queries

N+1 queries are the single most common performance problem in Rails applications, and they are usually the first thing worth fixing. The pattern happens when you load a collection of records, then loop over that collection and trigger a separate query for each one's association.

Here is a classic example. Say you want to display a list of articles along with each author's name.

articles = Article.all

articles.each do |article|
puts article.author.name
end

This looks harmless, but it generates one query to load all the articles, and then one additional query for every single article to load its author. If you have 50 articles, that is 51 queries instead of 2. This is where the term N+1 comes from: one query for the base collection, plus N queries for each associated record.

You can fix this using includes, which tells Active Record to load the associated records ahead of time.

articles = Article.includes(:author)

articles.each do |article|
puts article.author.name
end

Now Rails runs two queries total, regardless of how many articles there are.

SELECT "articles".* FROM "articles"
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5)

If you want a deeper walkthrough of diagnosing and fixing this exact issue in a real application, the CodeCurious article on fixing N+1 queries in Rails covers the full debugging process, from spotting the problem in the logs to verifying the fix.

includes vs preload vs eager_load. These three methods all solve the N+1 problem, but they behave differently under the hood, and picking the right one matters.

preload always runs a separate query for the association, similar to the example above. It never uses a JOIN.

Article.preload(:author)
# Two separate SELECT queries, same as includes without a WHERE on the association

eager_load always uses a LEFT OUTER JOIN to load the association in a single query.

Article.eager_load(:author).where(users: { active: true })
SELECT "articles".*, "users".* FROM "articles"
LEFT OUTER JOIN "users" ON "users"."id" = "articles"."author_id"
WHERE "users"."active" = TRUE

includes is the flexible option. It behaves like preload by default, running separate queries, but Rails automatically switches to a LEFT OUTER JOIN behavior if you reference a column from the association in a where clause.

As a general rule, use includes when you are not sure, use preload when you know you will not filter on the association, and use eager_load when you need to filter or order by a column on the associated table. The CodeCurious deep dive on includes vs preload vs eager_load walks through several real scenarios if you want to build a stronger intuition for choosing between them.


Load Only the Data You Need

One of the easiest ways to speed up your application is to simply stop loading data you do not use. Active Record gives you several tools for this.

select limits which columns are returned, instead of pulling every column with SELECT *.

Article.select(:id, :title, :created_at)

This is useful when you are rendering a list view that only needs a few fields, since it reduces both query time and memory usage.

pluck goes a step further and returns a plain array of values instead of full Active Record objects, skipping object instantiation entirely.

Article.pluck(:title)
# => ["Rails Performance Tips", "Understanding Active Record", ...]

pick is like pluck combined with first, useful when you only need a single value from a single row.

Article.where(slug: "my-article").pick(:id)

ids is a shortcut for plucking just the primary key column.

Article.published.ids
# => [1, 2, 5, 9]

exists? checks whether any matching record exists without loading it at all, which is dramatically faster than loading a record just to check its presence.

Article.where(author_id: 3).exists?

Compare that to the wasteful alternative:

Article.where(author_id: 3).first.present? # loads a full record just to check presence

any?, count, size, and length all sound similar but behave very differently depending on whether the collection has already been loaded into memory.

count always triggers a SELECT COUNT(*) query.

size uses count if the collection is not loaded, but uses the in-memory array length if it already is.

length always loads the full collection into memory first, then counts it in Ruby.

any? runs an efficient SELECT 1 ... LIMIT 1 query if the collection is not loaded, but checks the in-memory array otherwise.

Prefer exists? for presence checks, prefer size when the collection might already be loaded, and be careful with length, since it forces a full load of every record just to count them.


Optimize Large Datasets

Loading a large number of records into memory at once, even with select or pluck, can still strain your application. When you need to process thousands or millions of rows, batch processing becomes essential.

find_each loads records in batches behind the scenes, by default 1,000 at a time, and yields them to your block one at a time.

Article.find_each do |article|
ArticleIndexer.reindex(article)
end

This keeps memory usage flat no matter how large the table grows, because Rails never holds more than one batch in memory at a time.

find_in_batches works similarly but yields arrays of records instead of individual ones, which is useful when you want to process a whole batch at once.

Article.find_in_batches(batch_size: 500) do |batch|
ArticleMailer.digest_for(batch).deliver_later
end

Pagination is the right approach when you are displaying records to a user rather than processing them in the background. Gems like Pagy or Kaminari handle this efficiently by adding LIMIT and OFFSET to your query so you only ever load one page of results.

@articles = Article.order(created_at: :desc).page(params[:page])

Streaming records, using something like find_each combined with CSV export, is useful for generating large reports without loading the entire dataset into memory at once.

Use each only when you are confident the collection is small. The moment it could grow beyond a few hundred records, reach for find_each or pagination instead.


Optimize Joins

Joins let you filter or combine data across tables in a single query, but the different join methods in Active Record produce different SQL, and picking the wrong one can hurt performance or return unexpected results.

joins performs an inner join, returning only records that have a matching associated record.

Article.joins(:comments).where(comments: { flagged: true })
SELECT "articles".* FROM "articles"
INNER JOIN "comments" ON "comments"."article_id" = "articles"."id"
WHERE "comments"."flagged" = TRUE

left_joins (also aliased as left_outer_joins) performs a left outer join, returning all records from the base table even if there is no matching associated record.

Article.left_joins(:comments).where(comments: { id: nil })

This is useful for finding articles that have no comments at all, something an inner join could never return.

references is needed when you use includes and want to filter on the associated table's columns, since it tells Rails to actually join that table in SQL rather than run a separate query.

Article.includes(:comments).where(comments: { flagged: true }).references(:comments)

merge lets you combine scopes from different models cleanly, which keeps your query logic reusable and readable.

Article.joins(:author).merge(User.active)

This reuses the active scope already defined on the User model instead of duplicating its logic inside the Article query.

Joins are powerful, but they can also return duplicate rows when a record has multiple matching associations. Adding .distinct is often necessary when joining a has_many association to avoid counting or listing the same record multiple times.


Efficient Association Queries

Beyond basic joins and eager loading, there are several techniques specifically for making association heavy code efficient.

Nested eager loading lets you preload associations several levels deep in a single set of queries.

Article.includes(comments: :author)

This preloads comments for each article, and the author for each comment, avoiding N+1 queries at every level of the relationship.

Scoped associations let you define narrower, purpose built associations directly on the model.

class Article < ApplicationRecord
has_many :comments
has_many :published_comments, -> { where(published: true) }, class_name: "Comment"
end

This keeps filtering logic close to the model and avoids repeating the same where clause across the codebase.

Counter caches avoid running a COUNT query every time you need to know how many associated records exist, by storing the count directly on the parent record.

class Comment < ApplicationRecord
belongs_to :article, counter_cache: true
end

With a comments_count column on the articles table, article.comments.size becomes a simple column read instead of a database query.

Association caching happens automatically within a single request. Once you call article.comments, Rails caches the result on that object, so calling it again does not trigger a new query, as long as you are working with the same in-memory object.

Avoiding duplicate queries often comes down to being deliberate about eager loading up front rather than letting each part of your view or controller trigger its own lookup. If a chart, a partial, and a summary block all separately query the same association, restructure the code so it is only queried once. For a deeper look at how associations work under the hood in Rails, the CodeCurious guide on how Active Record associations work is a great companion resource.


Database Indexing

No amount of query optimization in Ruby can compensate for a missing index. Indexes let the database find matching rows quickly instead of scanning every row in a table, and they are one of the highest leverage changes you can make.

Single-column indexes speed up lookups and filters on a specific column.

class AddIndexToArticlesOnSlug < ActiveRecord::Migration[8.0]
def change
add_index :articles, :slug
end
end

Composite indexes speed up queries that filter on multiple columns together, and the column order matters. Put the column you filter on most often, or the one with an equality check, first.

add_index :articles, [:author_id, :published]

This index helps a query like Article.where(author_id: 3, published: true) far more than two separate single-column indexes would.

Foreign key indexes are easy to forget, but they are essential. Every belongs_to association should have a corresponding index on its foreign key column, since Rails constantly joins and filters on these columns.

add_index :comments, :article_id

Unique indexes enforce data integrity at the database level while also speeding up lookups, and they are the correct way to guarantee uniqueness rather than relying solely on a Rails validation.

add_index :users, :email, unique: true

Confirm an index is actually being used by running EXPLAIN and checking for an index scan rather than a sequential scan. Adding indexes without measuring impact is guessing. Adding them after confirming a slow query with EXPLAIN is optimization.


Query Caching

Reducing the number of queries you run in the first place is always the best optimization, but caching helps when the same query or the same rendered output is needed repeatedly.

Active Record query cache automatically caches identical queries within the same request, so if you call the exact same where clause twice in one request cycle, Rails will only hit the database once. This happens automatically and requires no configuration, but it is worth knowing about since it explains why repeated identical queries in logs sometimes only show up once.

Fragment caching caches rendered HTML fragments so Rails does not need to re-render the same view partial, or re-run the queries behind it, on every request. This is one of the most effective performance tools available in a typical Rails application, especially for content-heavy sites.

<% cache article do %>
<%= render article %>
<% end %>

If you want a complete walkthrough of setting this up, including nested caching and cache key invalidation, the CodeCurious article on how fragment caching works in Ruby on Rails is a strong complement to the query level techniques covered here.

Low-level caching lets you cache the result of any Ruby computation or query directly, using Rails.cache.

Rails.cache.fetch("popular_articles", expires_in: 12.hours) do
Article.published.order(views: :desc).limit(10).to_a
end

When caching helps and when it does not. Caching is excellent for data that does not change often or that is expensive to compute, like a homepage list of popular articles. It is a poor fit for highly personalized or rapidly changing data, since cache invalidation overhead can outweigh the benefit. Always optimize the underlying query first. Caching should reduce load on an already efficient query, not paper over a slow one.


Avoid Common Performance Mistakes

Even experienced Rails developers fall into a handful of recurring traps.

Loading every column with a bare Model.all when you only need a few fields wastes memory and bandwidth. Reach for select or pluck when you can.

Calling count repeatedly inside a loop or view triggers a fresh COUNT query every time. Cache the value, or use a counter cache column, instead.

Calling size incorrectly assumes size always avoids a query. It only does if the collection is already loaded. On an unloaded association, .size still triggers a COUNT query.

Using each instead of find_each on a large table loads every record into memory before processing a single one, a common cause of memory spikes in background jobs.

Missing indexes on foreign keys or frequently filtered columns are one of the most common causes of slow queries, and one of the easiest to fix once spotted with EXPLAIN.

Unnecessary joins add overhead and can produce duplicate rows. Only join tables you actually need to filter, sort, or select from.

Overusing eager loading by attaching includes to every query "just in case" can hurt performance by loading associations you never use. Eager load only what a given view actually needs.


Real-World Optimization Case Study

Let's optimize a small blog application with Users, Articles, Categories, Comments, and Tags, applying the techniques above step by step.

Starting point. The homepage controller looks like this.

def index
@articles = Article.all

@articles.each do |article|
article.author.name
article.category.name
article.comments.count
article.tags.map(&:name)
end
end

With 50 articles on the homepage, this triggers roughly 1 query for the articles, plus 50 for authors, 50 for categories, 50 count queries for comments, and 50 for tags. Over 200 queries to render a single page.

Step one: fix the N+1 queries.

@articles = Article.includes(:author, :category, :tags)

This drops the query count from over 200 down to around 4.

Step two: replace repeated counts with a counter cache. We add a comments_count column and set counter_cache: true on the Comment model's belongs_to :article association. Now article.comments_count reads a column instead of running a query, eliminating another 50 queries.

Step three: only select the columns we need. The homepage only displays the title, excerpt, and publish date.

@articles = Article
.select(:id, :title, :excerpt, :published_at, :author_id, :category_id)
.includes(:author, :category, :tags)

This reduces the amount of data pulled from the articles table, especially if it has large text columns like a full article body that the homepage does not need.

Step four: add missing indexes. Running EXPLAIN reveals that articles.category_id and articles.author_id do not have indexes, despite being used in joins.

add_index :articles, :category_id
add_index :articles, :author_id

Step five: add fragment caching. The homepage does not change on every request, so we wrap each article card in a cache block.

<% @articles.each do |article| %>
<% cache article do %>
<%= render "article_card", article: article %>
<% end %>
<% end %>

Result. After these five steps, the homepage goes from over 200 queries down to around 4 on a cache miss, and effectively zero database load on a cache hit. Every technique used here is one you can apply to your own application today.


Performance Checklist

Before deploying a Rails application, or any time you are reviewing a slow endpoint, run through this checklist.

  • Check your logs or Bullet output for N+1 queries.
  • Replace loops that trigger association queries with includes, preload, or eager_load.
  • Use select or pluck when you do not need full Active Record objects.
  • Use exists? instead of loading a record just to check presence.
  • Replace each with find_each for any collection that could grow large.
  • Confirm foreign key columns have indexes.
  • Run EXPLAIN on any query you suspect is slow.
  • Add counter caches for frequently displayed association counts.
  • Apply fragment caching to expensive or repeated view rendering.
  • Avoid eager loading associations a given view does not actually use.
  • Re-check query counts after each change to confirm the fix worked.

Frequently Asked Questions

How do I speed up Active Record? Eliminate N+1 queries with includes, preload, or eager_load, reduce the data you load with select or pluck, and confirm your most common queries have supporting indexes.

How do I detect slow queries? Watch your Rails logs in development, install Bullet to catch N+1 queries automatically, and use EXPLAIN or EXPLAIN ANALYZE to inspect a query's execution plan.

When should I use pluck? Whenever you need raw column values rather than full Active Record objects, such as a dropdown list or a list of IDs. It skips object instantiation entirely.

What is the difference between includes and joins? includes prevents N+1 queries by preloading associations, and by default runs separate queries rather than a SQL join. joins filters or combines data with an actual SQL join, but does not preload associations for later use in Ruby.

Does Active Record generate efficient SQL? It can, but it is not guaranteed. The SQL is only as efficient as the queries you write and the indexes that back them.

How do I optimize large datasets? Use find_each or find_in_batches to process records in manageable batches, and use pagination for anything displayed directly to users.

Should I use raw SQL instead of Active Record? In most cases, no. Raw SQL is worth considering only for complex reporting queries where Active Record's interface becomes awkward or you need a very specific execution plan.

How important are database indexes? Extremely. A well written query on a table without the right index can still be slow, while even a mediocre query on a well indexed table often performs fine.


Conclusion

Query optimization in Rails comes down to a small set of habits applied consistently: eliminate N+1 queries, load only the data you actually need, batch process large datasets, index the columns your queries filter and join on, and cache what is expensive to compute or render repeatedly.

The most impactful techniques covered here, fixing N+1 queries with proper eager loading, replacing full object loads with select and pluck, and adding the right indexes, will solve the vast majority of performance problems in a typical Rails application. Everything else is refinement.

Before you optimize anything, measure it. Use your logs, use Bullet, use EXPLAIN. Guessing at performance problems wastes time and sometimes makes things worse.

Understanding the SQL underneath your Active Record calls is what separates developers who write working code from developers who write code that scales. The more comfortable you get reading query plans and generated SQL, the better every Rails application you build will perform.

💌 Don’t miss out! Join my newsletter for web development tips, tutorials, and insights delivered straight to your inbox.

Thanks for reading & Happy coding! 🚀

Follow me on:

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.

    Enter valid email address

    Services Tailored to Your Needs


    coding

    Web & Mobile Development

    Custom websites and mobile apps built to be fast, modern, and user-friendly. From sleek landing pages to full-scale applications, I deliver solutions that engage your audience and grow your business.

    API development

    Seamlessly connect your systems with secure, scalable APIs. I design and integrate APIs that improve efficiency, reliability, and flexibility for your business processes.

    Database design and management

    Reliable database solutions tailored to your needs. I design, optimize, and maintain databases that ensure performance, security, and scalability for your applications.

    You might also like…

    Ruby on Rails Security Best Practices (2026 Guide)
    Ruby On Rails

    Ruby On Rails Security Best Practices (2026 Guide)

    By Jean Emmanuel Cadet
    Published on: Jul 22, 2026
    Ruby on Rails vs Laravel in 2026: Full Comparison
    Ruby On Rails

    Ruby On Rails Vs Laravel In 2026: Full Comparison

    By Jean Emmanuel Cadet
    Published on: Jul 20, 2026
    Using AI Coding Assistants for Ruby on Rails
    Ruby On Rails

    Using AI Coding Assistants For Ruby On Rails

    By Jean Emmanuel Cadet
    Published on: Jul 17, 2026
    CodeCurious

    Designed for those who view software as architecture and code as literature.

    Legal

    Terms & Conditions Privacy Policy Disclaimer

    CodeCurious © 2025 - 2026. All rights reserved. | Made with ♥ by @jecode93