- Home
- Blog
- Ruby & Rails Core
- ActiveRecord group and count — SQL GROUP BY in Rails
ActiveRecord group and count — SQL GROUP BY in Rails
How to use group and count in ActiveRecord for SQL GROUP BY. With having, order, and the SQL Rails generates. Rails 7/8 production examples with output.
SQL GROUP BY is one of the most-used aggregation patterns. ActiveRecord supports it directly, but the return type surprises developers coming from SQL.
Basic group and count
SQL:
SELECT status, COUNT(*) FROM orders GROUP BY status
ActiveRecord:
Order.group(:status).count
# => {"pending"=>12, "shipped"=>34, "cancelled"=>5}
The return type is a Hash, not a Relation. You cannot chain further after .count.
Grouping by Multiple Columns
SQL:
SELECT status, country, COUNT(*) FROM orders GROUP BY status, country
ActiveRecord:
Order.group(:status, :country).count
# => {["pending", "US"]=>8, ["pending", "UK"]=>4, ...}
Other Aggregates
Order.group(:status).sum(:total_cents)
Order.group(:status).average(:total_cents)
Order.group(:status).maximum(:total_cents)
Order.group(:status).minimum(:total_cents)
All return a Hash with the group value as key.
Adding HAVING
SQL:
SELECT status, COUNT(*) FROM orders GROUP BY status HAVING COUNT(*) > 10
ActiveRecord:
Order.group(:status).having("COUNT(*) > ?", 10).count
.having takes a string condition with the same sanitization rules as .where. Always use ? placeholders.
Ordering Grouped Results
Order.group(:status).order("count_all DESC").count
# The alias 'count_all' is what Rails uses for COUNT(*)
If you’re ordering by a specific aggregate column:
Order.group(:status).order("sum_total_cents DESC").sum(:total_cents)
With select and group Together
If you need both the grouped data and other columns:
Order.select("status, COUNT(*) as order_count, SUM(total_cents) as revenue")
.group(:status)
# Returns a Relation with virtual attributes order_count and revenue
Access them:
results = Order.select("status, COUNT(*) as order_count").group(:status)
results.each { |r| puts "#{r.status}: #{r.order_count}" }
Grouping by Date
SQL:
SELECT DATE(created_at), COUNT(*) FROM orders GROUP BY DATE(created_at)
ActiveRecord:
Order.group("DATE(created_at)").count
# => {"2026-09-01"=>45, "2026-09-02"=>62, ...}
For PostgreSQL, DATE_TRUNC is more precise:
Order.group("DATE_TRUNC('month', created_at)").count
The N+1 You Don’t Expect
Avoid this pattern:
Status.all.each do |status|
count = Order.where(status: status.name).count
# Separate query per status — this is N+1
end
Replace with:
Order.group(:status).count
# One query. Use the hash.
What Rails Generates
Order.group(:status).count
# SELECT status, COUNT(*) AS count_all FROM orders GROUP BY status
Always verify with .to_sql before adding .count (which forces execution):
Order.group(:status).to_sql
Quick Reference
| Goal | ActiveRecord |
|---|---|
| Count per group | .group(:col).count |
| Sum per group | .group(:col).sum(:amount) |
| Filter groups | .group(:col).having(“COUNT(*) > ?”, n) |
| Group by date | .group(“DATE(created_at)”).count |
| Group + custom select | .select(“col, COUNT(*) as n”).group(:col) |
Was this article helpful?
Your feedback helps us improve our content
How We Verify Conversions
Every conversion shown on this site follows a strict verification process to ensure correctness:
- Compare results on same dataset — We run both SQL and ActiveRecord against identical test data and verify results match
-
Check generated SQL with
to_sql— We inspect the actual SQL Rails generates to catch semantic differences (INNER vs LEFT JOIN, WHERE vs ON, etc.) - Add regression tests for tricky cases — Edge cases like NOT EXISTS, anti-joins, and predicate placement are tested with multiple scenarios
- Tested on Rails 8.1.1 — All conversions verified on current Rails version to ensure compatibility
Last updated: September 23, 2026
Try These Queries in Our Converter
See the SQL examples from this article converted to ActiveRecord—and compare the SQL Rails actually generates.
Deep Dive into ActiveRecord
Raza Hussain
Full-stack developer specializing in Ruby on Rails, React, and modern JavaScript. 15+ years upgrading and maintaining production Rails apps. Led Rails 4/5 → 7 upgrades with 40% performance gains, migrated apps from Heroku to Render cutting costs by 35%, and built systems for StatusGator, CryptoZombies, and others. Available for Rails upgrades, performance work, and cloud migrations.
More on Aggregations & Grouping
Added select() to Limit Columns. Performance Improved 40%. Stopped Loading Data I Didn't Need.
Added select() to stop loading unused columns. Response times dropped 40%. Here's the pattern, what to watch for with missing attributes, and when worth it.
LEFT JOIN + WHERE in ActiveRecord: The Trap That Turns It Into INNER JOIN
Adding WHERE after LEFT JOIN in Rails silently converts it to INNER JOIN. Here's why it happens, how to spot it, and the fix with .merge() or subqueries.
destroy_all vs delete_all in Rails: Performance, Callbacks, and When to Use Each
destroy_all runs callbacks and triggers dependent destroys. delete_all is a single SQL DELETE. Here's when each is correct and what breaks if you pick wrong.
Leave a Response
Responses (0)
No responses yet
Be the first to share your thoughts