The 3 ActiveRecord Queries Slowing Down Your Rails App
Every Rails app has the same three performance problems. They don’t show up in your test suite. They barely register in development with 10 rows in your database. Then you deploy to production with real data, and your response times quietly climb from 50ms to 500ms to 5 seconds.
The good news: all three are straightforward to find and fix. Let’s look at each one.
1. N+1 queries
This is the classic. You load a collection, then access an association on each item, and ActiveRecord fires a separate query for every single row.
# Controller
@posts = Post.all
# View
<% @posts.each do |post| %>
<p><%= post.author.name %></p>
<% end %>
Your query log looks like this:
SELECT "posts".* FROM "posts"
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 1
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 2
SELECT "authors".* FROM "authors" WHERE "authors"."id" = 3
-- ... one for every post
With 100 posts, that’s 101 queries. With 1,000 posts, it’s 1,001. The fix takes five seconds:
@posts = Post.includes(:author)
Now ActiveRecord loads all authors in a single query:
SELECT "posts".* FROM "posts"
SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (1, 2, 3, ...)
Two queries instead of 101. Done.
includes vs preload vs eager_load
These three do subtly different things:
includeslets ActiveRecord decide the strategy. Usually it fires two separate queries (likepreload), but if you reference the association in awhereclause, it switches to a JOIN.preloadalways fires separate queries. Use this when you know you don’t need to filter by the association.eager_loadalways uses a LEFT OUTER JOIN. Use this when you need to filter or sort by the associated table’s columns.
# Filter posts by author name, needs eager_load or includes
Post.eager_load(:author).where(authors: { active: true })
# Just displaying author info, preload is fine
Post.preload(:author)
Nesting works the way you would hope, and it is worth learning the syntax properly because most real N+1s are two levels deep, not one:
Post.includes(comments: [:author, { replies: :author }])
One thing to know about eager_load: because it is a LEFT OUTER JOIN, a has_many association multiplies the rows. Combine it with limit and Rails has to work around that by running a subquery to pick the ids first, which is often slower than the two-query preload it would have chosen for you. If you are only rendering the association, say preload and mean it.
The preload that silently does nothing
This is the one that costs people an afternoon. You add includes, the N+1 does not go away, and the eager-loaded query is sitting right there in the log doing nothing.
# Controller
@posts = Post.includes(:comments)
# View
<% @posts.each do |post| %>
<%= post.comments.where(approved: true).count %>
<% end %>
post.comments is preloaded. post.comments.where(approved: true) is a different relation, so ActiveRecord throws away the preloaded records and goes back to the database, once per post, plus a COUNT on top. Any call that changes the scope defeats the preload: where, order, limit, includes on the association itself.
The fix is to do the filtering in Ruby, on records you already have in memory:
post.comments.count { |c| c.approved? }
Or, if the filtered set is what you actually care about, preload that instead:
Post.includes(:approved_comments) # has_many :approved_comments, -> { where(approved: true) }, class_name: "Comment"
The general rule: once a collection is preloaded, touch it only with Enumerable methods. The moment you reach for a query method, you are back on the database.
Use the Bullet gem
Don’t rely on manual code review to catch N+1s. The Bullet gem detects them automatically in development and test:
# Gemfile
group :development, :test do
gem "bullet"
end
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
end
Bullet will pop up a browser alert every time it detects an N+1 query. It also catches unnecessary eager loading, where you’re preloading associations you never actually use.
Bullet is heuristic, though. It watches for the same association being loaded repeatedly, so it can miss an N+1 that only fires a handful of times in a request, and it needs the code path to actually run before it says anything. Prosopite takes a different approach and inspects the queries themselves for the repeated-query fingerprint, which catches some cases Bullet does not. Running both is not unreasonable in a large app.
Or make Rails refuse to lazy load at all
Rails 6.1 added strict_loading, which turns a silent N+1 into a loud exception. Instead of hoping a tool notices, you tell ActiveRecord that a record is not allowed to lazy load its associations:
post = Post.strict_loading.first
post.author
# => ActiveRecord::StrictLoadingViolationError: `Author` called on `Post` is marked for strict_loading
You can set it per association, per model, or globally:
class Post < ApplicationRecord
# Just this association
has_many :comments, strict_loading: true
end
class Author < ApplicationRecord
# Every association on this model
self.strict_loading_by_default = true
end
Turning it on globally in an existing app will light up like a Christmas tree, so do it in stages. Set the violation to log rather than raise while you work through the backlog:
# config/environments/development.rb
config.active_record.action_on_strict_loading_violation = :log
Then flip it to :raise in test and development once the log is quiet. This is the only approach in this post that actually prevents N+1s from being written, rather than finding them after the fact, and it is the one I would reach for first on a codebase I intend to keep for years.
2. Loading columns you don’t need
By default, ActiveRecord loads every column on every query. If your users table has 30 columns including a bio text field and a settings JSON blob, every User.all loads all of it, even if you only need names and emails.
# Loads everything, including that 10KB bio column
users = User.where(active: true)
emails = users.map(&:email)
The query log:
SELECT "users".* FROM "users" WHERE "users"."active" = TRUE
That * is doing a lot of unnecessary work. Three alternatives, depending on what you need:
select when you still want model objects
users = User.where(active: true).select(:id, :email, :name)
You get User objects, but only with the columns you specified. Accessing user.bio on these objects will raise an ActiveModel::MissingAttributeError, which is actually helpful because it catches assumptions.
Helpful, but only if the assumption is in code you exercise. A trimmed select in a controller and a user.bio.present? inside a rarely rendered partial is a bug that ships. Keep the select close to the code that consumes the result, and be wary of putting one on a relation that gets passed down through several layers or into a serializer you do not control.
select also takes SQL, which is how you get computed values without a second query:
User.select("users.*, (SELECT COUNT(*) FROM posts WHERE posts.user_id = users.id) AS posts_count")
# each user now responds to posts_count
pluck when you just need raw values
emails = User.where(active: true).pluck(:email)
# => ["alice@example.com", "bob@example.com", ...]
pluck skips model instantiation entirely. No ActiveRecord objects are created. You get a flat array of values. For extracting data to pass around, this is dramatically faster and lighter on memory.
# pluck multiple columns for an array of arrays
User.where(active: true).pluck(:id, :email)
# => [[1, "alice@example.com"], [2, "bob@example.com"]]
Don’t use map when you mean pluck
This is a common anti-pattern:
# Bad: loads all User objects, then extracts one field
User.where(active: true).map(&:email)
# Good: never creates User objects at all
User.where(active: true).pluck(:email)
The map version loads full ActiveRecord objects into memory, instantiates each one, then throws them away after reading a single attribute. With 10,000 users, the memory difference is significant.
Two things about pluck that are easy to get wrong.
It does not deduplicate. pluck returns one entry per row, so plucking a foreign key off a join gives you repeats. Say distinct if you mean distinct, and let the database do it rather than calling uniq on a huge array in Ruby:
Comment.joins(:post).distinct.pluck("posts.author_id")
And pluck is a query, so it becomes its own N+1 the moment it appears inside a loop:
# One query per post
posts.each { |post| ids = post.comments.pluck(:id) }
On an already loaded relation, Rails 6 and later is smart enough to read the values off the records it has rather than re-querying, but that only saves you when the records are genuinely loaded. Inside a loop over a preloaded association, prefer plain Ruby:
ids = post.comments.map(&:id)
The same instinct applies when you are extracting and filtering at once. map followed by compact walks the collection twice and builds a throwaway array in between, and filter_map (Ruby 2.7 and later) does it in one pass:
# Two passes, two arrays
users.map { |u| u.email if u.confirmed? }.compact
# One pass
users.filter_map { |u| u.email if u.confirmed? }
3. count vs size vs length
These three methods all return the number of records, but they generate very different SQL depending on context. Using the wrong one can mean the difference between a cheap in-memory check and an expensive database query.
count always hits the database
posts = Post.where(user_id: 1)
posts.count
SELECT COUNT(*) FROM "posts" WHERE "posts"."user_id" = 1
Every call to count fires a SELECT COUNT(*) query. If you call it twice, it queries twice. It never caches.
length always loads the full collection
posts = Post.where(user_id: 1)
posts.length
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1
length loads every record into memory as ActiveRecord objects, then counts the resulting array. If you only need the count, this is pure waste.
size is smart about it
posts = Post.where(user_id: 1)
# If not yet loaded, size does a COUNT query
posts.size # => SELECT COUNT(*)
# If already loaded, size counts the in-memory array
posts.to_a # loads records
posts.size # no query, counts the array
size checks whether the relation has already been loaded. If it has, it counts the in-memory array (like length). If it hasn’t, it does a COUNT query (like count). It’s almost always what you want.
The rule of thumb
- Use
sizeas your default. It does the right thing in both loaded and unloaded contexts. - Use
countwhen you explicitly want a fresh database count, even if records are already loaded. - Use
lengthonly when you know the records are loaded and you specifically want to avoid a database call. In practice,sizehandles this case too.
The most common mistake is calling count inside a loop or view partial where the records were already eager-loaded. Each call is a wasted round trip to the database.
If you only want to know whether there is anything, don’t count
Counting to answer a yes-or-no question is the version of this bug that hides in plain sight, because if post.comments.count > 0 reads perfectly fine:
post.comments.exists? # SELECT 1 AS one FROM comments WHERE ... LIMIT 1
post.comments.any? # same, when the relation is not loaded
post.comments.count > 0 # SELECT COUNT(*) over every matching row
post.comments.present? # loads every comment into memory, then asks if the array is empty
exists? stops at the first matching row. count scans them all. On a table with a few hundred rows nobody notices, and on a table with two million rows it is the difference between a millisecond and a full index scan.
present? is the nasty one, because it is the most Rails-looking of the four. On a relation, blank? is defined in terms of the loaded records, so calling present? loads the entire collection just to find out whether it has at least one element. Use exists? when you are asking the database and any? when the records are already in memory. Note that any? with a block always loads the records, since it has to run your block against each one.
Counter caches, for when the count is the whole page
If a count appears on a listing page, once per row, no amount of choosing between count and size will save you: you are doing one query per row either way. That is what counter_cache is for.
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
# Migration
add_column :posts, :comments_count, :integer, default: 0, null: false
Rails now maintains posts.comments_count on create and destroy, and post.comments.size reads the column instead of querying. Two caveats worth knowing before you reach for it. post.comments.count still fires a COUNT, because count means “ask the database”, so the cache only helps if the calling code says size. And the counter is maintained by ActiveRecord callbacks, so anything that deletes rows without instantiating models, delete_all, a raw SQL delete, a database-level cascade, leaves the number wrong. Keep a reset_counters task around for when that happens.
Finding these before production does
Every fix above is easy. Noticing is the entire problem, and the only reliable way to notice is to make query counts visible while you work.
The cheapest habit is to actually read the development log. If you want the queries in a console session too:
ActiveRecord::Base.logger = Logger.new($stdout)
For a single slow query, ask the database rather than guessing. Rails 7 forwards the analyze option, so you get the real plan and the real row counts instead of the estimates:
Post.includes(:author).where(published: true).explain(analyze: true)
Best of all, write the query count down as a test. ActiveSupport::Notifications publishes a sql.active_record event for every query, so a small helper turns “this endpoint should not N+1” into something CI enforces:
def count_queries
count = 0
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
count += 1 unless payload[:name] == "SCHEMA" || payload[:sql].start_with?("BEGIN", "COMMIT")
end
yield
count
ensure
ActiveSupport::Notifications.unsubscribe(subscriber)
end
test "post index does not N+1" do
create_list(:post, 5, :with_author)
assert_operator count_queries { get posts_path }, :<=, 5
end
The trick that makes this test worth having is creating more than one record. A fixture set with a single post passes an N+1 test happily, because 1 + 1 is a perfectly reasonable number of queries. Five records, and the assertion has teeth.
In the browser, rack-mini-profiler puts a query count badge in the corner of every page in development, with the SQL one click away. It is the fastest feedback loop of anything here: you see the number go up as you type.
Putting it all together
These three problems, N+1 queries, unnecessary column loading, and count semantics, account for the vast majority of ActiveRecord performance issues in real Rails apps. None of them are complex to fix. The hard part is finding them.
Enable the Bullet gem. Turn on strict_loading so the next one cannot be written quietly. Read your query logs in development. Use explain on the slow ones. And when your test suite starts producing hash diffs from unexpected database state, you’ll want a fast way to read them.
Comparing ActiveRecord output in your tests? Try RubyHash to paste hash diffs and see exactly which attributes changed.
Enjoyed this post?
Subscribe to get notified when we publish more Ruby and Rails content.