ruby 61 lines · 3 tabs

Memory-Safe “top tags” aggregation with pluck + group

Shared by codesnips Jan 2026
3 tabs
class Tag < ApplicationRecord
  has_many :taggings, dependent: :destroy

  scope :top, ->(limit = 20) {
    joins(:taggings)
      .group(Arel.sql("tags.id"))
      .order(Arel.sql("COUNT(taggings.id) DESC"))
      .limit(limit)
      .pluck(Arel.sql("tags.id"), Arel.sql("COUNT(taggings.id)"))
  }

  def self.each_tag_count(batch_size: 1_000)
    return enum_for(:each_tag_count, batch_size: batch_size) unless block_given?

    joins(:taggings)
      .group(Arel.sql("tags.id"))
      .in_batches(of: batch_size) do |relation|
        relation.pluck(Arel.sql("tags.id"), Arel.sql("COUNT(taggings.id)")).each do |id, count|
          yield(id, count)
        end
      end
  end
end
3 files · ruby Explain with highlit

Computing a "top tags" leaderboard over a large taggings table is a classic place where a naive Rails query quietly blows up memory. Calling Tagging.all.each or building an in-memory Hash from millions of loaded ActiveRecord objects allocates one heavy object per row plus the association graph, which is exactly what this snippet avoids. The pattern here pushes the counting work into the database and pulls back only the two scalar columns that matter.

In Tag model, the top scope is the core idea: joins(:taggings) moves the aggregation to SQL, group(:id) collapses rows by tag, and pluck(Arel.sql("tags.id"), Arel.sql("COUNT(taggings.id)")) returns a plain array of [id, count] pairs rather than materialized model instances. pluck is the key — it bypasses ActiveRecord object instantiation entirely, so the memory footprint is proportional to the number of distinct tags, not the number of taggings. order(Arel.sql("COUNT(...) DESC")) and limit keep the result bounded regardless of table size. Wrapping the raw fragments in Arel.sql marks them as trusted, silencing Rails' unsafe-SQL deprecation without disabling protection everywhere.

For even larger windows, each_tag_count uses in_batches so a huge grouped result set is streamed in chunks instead of held all at once — a useful safety valve when the distinct-tag count itself is large.

In TagLeaderboard service, the plucked pairs are the input to a second cheap query: where(id: ids) with index_by(&:id) builds a lookup so names can be attached in tag order without an N+1. Only the top slice of tags is ever hydrated into real objects, which is the whole point of separating counting from display. The returned Struct keeps the shape explicit and allocation-light.

TagsController shows the caller: it fetches a bounded leaderboard and caches it under a key derived from Tagging.maximum(:updated_at), so the expensive aggregation runs only when data actually changes. The trade-off is that pluck returns raw values with no callbacks or typecasting beyond the adapter's, and the SQL fragments are database-specific — acceptable when the goal is a fast, memory-safe read path. This approach is the right reach whenever an aggregate is needed over a table far larger than its distinct grouping keys.


Related snips

Share this code

Here's the card — post it anywhere.

Memory-Safe “top tags” aggregation with pluck + group — share card
Link copied