Here’s the thing: count always hits the database. Every time. Even if you already have the records sitting in memory.
size is smarter. It checks whether the association is already loaded. If so, it just counts the array. If not, it fires the query. One method that does the right thing in both contexts.
This matters more than it sounds.
The N+1 you didn’t know you had
Say you’re rendering a list of posts with comment counts:
@posts = Post.includes(:comments).limit(20)
<% @posts.each do |post| %>
<%= post.comments.count %> comments
<% end %>
You did the eager load. You thought you were being responsible. But count doesn’t care—it sends 20 additional queries anyway.
Try it yourself:
posts = Post.includes(:comments).limit(3)
puts "eager loaded posts with comments"
puts ""
posts.each do |post|
puts "#{post.title}: #{post.comments.count} comments"
end
eager loaded posts with comments
Post Load (0.1ms) SELECT "posts".* FROM "posts" LIMIT 3
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3)
Comment Count (0.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1
Getting Started with Ruby: 3 comments
Comment Count (0.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 2
Rails is Magic: 2 comments
Comment Count (0.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 3
Advanced ActiveRecord: 1 comments
=> [#<Post id: 1, title: "Getting Started with Ruby", body: "Ruby is great.", published: true, created_at: "2026-05-30 18:06:37.095378000 +0000", updated_at: "2026-05-30 18:06:37.095378000 +0000">, #<Post id: 2, title: "Rails is Magic", body: "Convention over configuration.", published: true, created_at: "2026-05-30 18:06:37.121766000 +0000", updated_at: "2026-05-30 18:06:37.121766000 +0000">, #<Post id: 3, title: "Advanced ActiveRecord", body: "Query optimization matters.", published: true, created_at: "2026-05-30 18:06:37.124301000 +0000", updated_at: "2026-05-30 18:06:37.124301000 +0000">]Three posts, three extra COUNT queries. Now change count to size and run it again. The COUNT queries disappear.
Understanding loaded?
ActiveRecord Relations are lazy. They don’t execute until you actually need the data.
posts = Post.all
puts "posts.loaded? => #{posts.loaded?}"
puts ""
posts = posts.where(published: true)
puts "after adding where clause:"
puts "posts.loaded? => #{posts.loaded?}"
puts ""
result = posts.map { |p| p.title }
puts "after iterating:"
puts "posts.loaded? => #{posts.loaded?}"
puts ""
puts "titles: #{result}"
posts.loaded? => false
after adding where clause:
posts.loaded? =>
Post Load (0.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = 1
after iterating:
posts.loaded? => true
titles: ["Getting Started with Ruby", "Rails is Magic", "Advanced ActiveRecord"]
=> nilThe Relation stays unloaded until you do something that requires the actual records—iteration, to_a, first, etc.
How size uses this
Here’s a simplified version of what size does under the hood:
def size
if loaded?
records.length
else
count
end
end
If the records are already in memory, count them directly. If not, ask the database.
count, by contrast, always asks the database. It doesn’t check. It doesn’t care.
See it in action:
post = Post.includes(:comments).first
puts "comments loaded? #{post.comments.loaded?}"
puts ""
puts "calling count..."
result = post.comments.count
puts "count returned: #{result}"
puts ""
puts "calling size..."
result = post.comments.size
puts "size returned: #{result}"
Post Load (0.1ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1
comments loaded? true
calling count...
Comment Count (0.0ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1
count returned: 3
calling size...
size returned: 3
=> nilcount fires a query. size just returns the array length.
When count is still the right call
count isn’t wrong—it’s just specific. Use it when:
You’re just displaying a number and never touching the records. count is fine. One query either way.
puts "total comments: #{Comment.count}"
Comment Count (0.1ms) SELECT COUNT(*) FROM "comments"
total comments: 6
=> nilYou need the count to reflect the current database state. If other processes might be inserting records, count gives you the live number. size on a stale loaded? association gives you the stale number.
You’re using count with conditions. post.comments.where(approved: true).count is a database operation regardless. size won’t help here.
post = Post.includes(:comments).first
puts "all comments loaded? #{post.comments.loaded?}"
puts ""
puts "counting approved comments..."
post.comments.where(approved: true).count
Post Load (0.1ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1
Comment Load (0.0ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1
all comments loaded? true
counting approved comments...
Comment Count (0.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 AND "comments"."approved" = 1
=> 2Adding a where clause creates a new Relation that isn’t loaded, so size would fire a query anyway.
The footgun is using count on something you’ve already loaded. That’s when you’re paying for a query you don’t need.
The size footgun: unsaved records
size counts what’s in memory. If you’ve built records that haven’t been saved yet, size includes them. count doesn’t.
post = Post.first
puts "comments in database: #{post.comments.count}"
puts ""
post.comments.build(author: 'Ghost', content: 'I am not saved')
post.comments.build(author: 'Phantom', content: 'Neither am I')
puts "size says: #{post.comments.size}"
puts "count says: #{post.comments.count}"
Post Load (0.1ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1
Comment Count (0.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1
comments in database: 3
Comment Count (0.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1
size says: 5
Comment Count (0.1ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1
count says: 3
=> nilThis bites you in forms. You build a nested record for the form, then somewhere else you display a count:
def new
@post = Post.find(params[:id])
@post.comments.build # for the nested form
end
This post has <%= @post.comments.size %> comments
The fix depends on what you actually want. If you want persisted records only, use count. If you want the in-memory state including unsaved records, use size. Just know which one you’re asking for.
When you don’t want to load the records at all
Sometimes the problem isn’t count vs size—it’s that you don’t want to load the associated records in the first place. You just want the counts, and loading thousands of comment objects to count them is wasteful.
Two approaches: a subquery or a join with grouping.
Subquery:
Post.select('posts.*, (SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id) AS comments_count')
Join + group:
Post.left_joins(:comments).group(:id).select('posts.*, COUNT(comments.id) AS comments_count')
Both give you a comments_count attribute on each post without instantiating a single Comment object.
posts = Post.left_joins(:comments).group(:id).select('posts.*, COUNT(comments.id) AS comments_count')
posts.each do |post|
puts "#{post.title}: #{post.comments_count} comments"
end
Post Load (0.2ms) SELECT posts.*, COUNT(comments.id) AS comments_count FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" GROUP BY "posts"."id"
Getting Started with Ruby: 3 comments
Rails is Magic: 2 comments
Advanced ActiveRecord: 1 comments
Draft Post: 0 comments
=> [#<Post id: 1, title: "Getting Started with Ruby", body: "Ruby is great.", published: true, created_at: "2026-05-30 18:06:37.095378000 +0000", updated_at: "2026-05-30 18:06:37.095378000 +0000", comments_count: 3>, #<Post id: 2, title: "Rails is Magic", body: "Convention over configuration.", published: true, created_at: "2026-05-30 18:06:37.121766000 +0000", updated_at: "2026-05-30 18:06:37.121766000 +0000", comments_count: 2>, #<Post id: 3, title: "Advanced ActiveRecord", body: "Query optimization matters.", published: true, created_at: "2026-05-30 18:06:37.124301000 +0000", updated_at: "2026-05-30 18:06:37.124301000 +0000", comments_count: 1>, #<Post id: 4, title: "Draft Post", body: "Work in progress...", published: false, created_at: "2026-05-30 18:06:37.125962000 +0000", updated_at: "2026-05-30 18:06:37.125962000 +0000", comments_count: 0>]One query, no N+1, no memory overhead.
The tradeoffs:
The subquery is easier to read, but it’s a correlated subquery—it runs once per row. With 10 posts, that’s 10 subquery executions. With 1000 posts, that’s 1000.
The join approach does one pass through the data and lets the database optimize the whole operation. It scales better.
Use the subquery when you’re fetching a handful of records and readability matters. Use the join when you’re fetching lists or when performance matters.
If you’re displaying counts constantly and they don’t need to be real-time, counter_cache is worth considering—it stores the count on the parent record and keeps it updated automatically. Zero queries.
The real cost
One unnecessary count query is nothing. But render a list of 50 items with two count calls each, and you’ve added 100 queries to your page load. That’s the kind of thing that shows up in your APM as “why is this endpoint slow?” with no obvious cause.
The fix is one word: count → size.
Summary
-
sizechecksloaded?first, skips the query if possible -
countalways queries - use
sizeas the default - use
countwhen you explicitly want fresh database state or haven’t loaded the association
That’s it. Small change, easy to remember, occasionally saves you from debugging phantom N+1s.