Prefer `size` over `count` - Josh Brody Prefer size over count | Josh Brody
Back

Prefer `size` over `count`

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:

1234567
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
This does not play nicely with iOS.
loads ~76MB Rails environment
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.

1234567891011121314
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}"
This does not play nicely with iOS.
loads ~76MB Rails environment
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"]
=> nil

The 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:

123456789101112
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}"
This does not play nicely with iOS.
loads ~76MB Rails environment
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
=> nil

count 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.

1
puts "total comments: #{Comment.count}"
This does not play nicely with iOS.
loads ~76MB Rails environment
Comment Count (0.1ms)  SELECT COUNT(*) FROM "comments"
total comments: 6
=> nil

You 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.

123456
post = Post.includes(:comments).first
puts "all comments loaded? #{post.comments.loaded?}"
puts ""

puts "counting approved comments..."
post.comments.where(approved: true).count
This does not play nicely with iOS.
loads ~76MB Rails environment
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
=> 2

Adding 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.

123456789
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}"
This does not play nicely with iOS.
loads ~76MB Rails environment
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
=> nil

This 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.

1234
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
This does not play nicely with iOS.
loads ~76MB Rails environment
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: countsize.

Summary

  • size checks loaded? first, skips the query if possible
  • count always queries
  • use size as the default
  • use count when 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.

Stay in the loop

Occasional essays on design, tools, and the craft of building things. No spam, unsubscribe anytime.

Ambient weather

The background of this site reflects the current weather and time of day in Saint Paul. The orbs shift in color and behavior based on what's happening outside my window.

Learn more about how this works