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)
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.
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.
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.
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.
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. Read your query logs in development. Use explain on slow queries. 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.