投稿日:2026/7/27
更新日:2026/7/27

ActiveRecord の group は「集計の単位を決めるスイッチ」。count / minimum / maximum / sum / average を「全体で1回」から「グループごとに1回ずつ」に切り替える。
例として、顧客(customers)と注文(orders)の 1対多を使う。
class Customer < ApplicationRecord
has_many :orders
end
class Order < ApplicationRecord
belongs_to :customer
end
元の orders テーブル:
| id | customer_id | amount | created_at |
|---|---|---|---|
| 101 | 1(田中) | 3,000 | 7/20 |
| 102 | 1(田中) | 5,000 | 7/21 |
| 103 | 2(佐藤) | 1,000 | 7/22 |
group(:customer_id) すると、customer_id が同じ行が1グループにまとめられる。
グループ 1(田中): [101(7/20, 3000), 102(7/21, 5000)] ← 2行が1グループに
グループ 2(佐藤): [103(7/22, 1000)] ← 1行が1グループに
3行の表が 2グループになった。ここが出発点。
グループの中には複数行が入っているので、「どの行の値か」を1つに決める必要がある。
それを決めるのが集計関数。
Order.group(:customer_id).count
# => { 1 => 2, 2 => 1 } 各グループの行数
Order.group(:customer_id).minimum(:created_at)
# => { 1 => 7/20, 2 => 7/22 } 各グループの最小値 = 初回注文日時
Order.group(:customer_id).maximum(:created_at)
# => { 1 => 7/21, 2 => 7/22 } 各グループの最大値 = 最新注文日時
Order.group(:customer_id).sum(:amount)
# => { 1 => 8000, 2 => 1000 } 各グループの合計金額
Order.group(:customer_id).average(:amount)
# => { 1 => 4000, 2 => 1000 } 各グループの平均金額
戻り値が Hash(キー = グループの値、値 = 集計結果)になるのが Rails の特徴。
SQL でいうと以下と同じ。
SELECT customer_id, MIN(created_at)
FROM orders
GROUP BY customer_id;
この対比が group の役割を一番はっきり示している。
| 書き方 | 結果 |
|---|---|
Order.minimum(:created_at) |
2026-07-20 10:00 ← 全体で1つの値 |
Order.group(:customer_id).minimum(:created_at) |
{ 1 => ..., 2 => ... } ← 顧客ごとに1つずつ |
「顧客ごとの初回注文日時」を1クエリで全員分作れる。
Ruby でループを回して min を取る必要がない。
# ❌ N+1 になる
Customer.all.to_h { |c| [c.id, c.orders.minimum(:created_at)] }
# ⭕ 1クエリ
Order.group(:customer_id).minimum(:created_at)
group とセットでよく出るのが having。
| 何を絞るか | いつ効くか | |
|---|---|---|
where |
行を絞る | グループ化の前 |
having |
グループを絞る | グループ化の後 |
# 注文が10件以上ある顧客だけ
Order.group(:customer_id).having('COUNT(*) >= 10').count
# => { 7 => 12, 15 => 30 }
COUNT(*) >= 10 はグループ化して集計しないと分からない条件なので、where では書けない。
逆に「キャンセル済みを除く」のような行単位の条件は where で書く(グループ化前に減らした方が速い)。
SQL の評価順はこうなっている。
1. FROM … 対象テーブル
2. WHERE … 行を絞る(グループ化前)
3. GROUP BY … グループにまとめる
4. HAVING … グループを絞る(集計値で判定できる)
5. SELECT … 集計値を取り出す
6. ORDER BY … 並べる
WHERE の時点ではまだグループが存在しないので COUNT(*) を書けない、と理解しておくと迷わない。
where と having を組み合わせると、こういう条件が1クエリで書ける。
from = Time.current.last_month.beginning_of_month
to = Time.current.beginning_of_month
Order.
where(status: :paid). # 行レベルの絞り込み
group(:customer_id). # 顧客ごとにまとめる
having('MIN(orders.created_at) >= ? AND MIN(orders.created_at) < ?', from, to).
minimum(:created_at)
# => { 顧客ID => 初回注文日時, ... }
「初回注文(= MIN(created_at))が先月」はグループ単位の条件なので having。
「支払い済みだけ」は行単位の条件なので where。役割が違う。
having に SELECT の別名は使えないことがあるMySQL は許すが PostgreSQL は許さない。移植性を考えるなら having の中で集計式をもう一度書く。
# PostgreSQL では NG(first_ordered_at は HAVING の時点で未定義)
.select('MIN(created_at) AS first_ordered_at').having('first_ordered_at >= ?', from)
# OK
.having('MIN(orders.created_at) >= ?', from)
Order.group(:customer_id, :status).count
# => { [1, "paid"] => 2, [2, "pending"] => 1 }
MySQL の ONLY_FULL_GROUP_BY(8.0 以降デフォルト有効)や PostgreSQL では、
グループ化した列と集計関数以外を並べるとエラーになる。
# NG: amount はグループ内で1つに決まらない
Order.group(:customer_id).pluck(:customer_id, :amount)
# OK: 集計関数で1つに畳む
Order.group(:customer_id).pluck(
:customer_id, Arel.sql('MIN(created_at)'), Arel.sql('MAX(created_at)')
)
group で取れるのはあくまで集計値。
「初回注文のレコード全体(id も amount も欲しい)」はサブクエリやウィンドウ関数の領域になる。
MIN(amount) は「そのグループの最小金額」であって「初回注文の金額」ではない。
最小の created_at と最小の amount が同じ行から来る保証はどこにもない。
行そのものが欲しいならウィンドウ関数(PostgreSQL なら DISTINCT ON でも可)を使う。
-- 顧客ごとの「初回注文のレコード全体」
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at) AS rn
FROM orders
) t
WHERE rn = 1;
group(...).count の後は Hash(=Ruby側)Order.group(:customer_id).count.select { |_, v| v >= 10 } # 全グループを取得してから Ruby で絞る
Order.group(:customer_id).having('COUNT(*) >= 10').count # DB 側で絞る(こちらが基本)
前者は全グループ分のレコードを転送してしまう。件数が増えると効いてくるので having を使う。
なお Hash#size はグループ数になるので、「条件を満たす顧客が何人いるか」は次で取れる。
Order.group(:customer_id).having('COUNT(*) >= 10').count.size
joins は1対多で行を増やすので、そのまま count すると「注文の行数」を数えることになる。
「顧客が何人か」を数えたいなら distinct(= COUNT(DISTINCT customers.id))を挟む。
# 都道府県ごとの注文数(1人が3回注文すれば 3)
Customer.joins(:orders).group('customers.prefecture').count
# 都道府県ごとの「注文したことがある顧客数」(1人が3回注文しても 1)
Customer.joins(:orders).group('customers.prefecture').distinct.count
# 顧客ごとの注文件数
Order.group(:customer_id).count
# 注文が10件以上ある顧客の人数
Order.group(:customer_id).having('COUNT(*) >= 10').count.size
# group の対象は他テーブルの列でもOK
Order.joins(:customer).group('customers.prefecture').count
# 顧客ごとの「最初と最後の注文日」を同時に取る
Order.group(:customer_id).pluck(
:customer_id, Arel.sql('MIN(created_at)'), Arel.sql('MAX(created_at)')
).first(3)
# 月ごとの売上(日付関数でグループ化)
Order.group(Arel.sql("DATE_TRUNC('month', created_at)")).sum(:amount)
group は集計の単位を決める。付けると戻り値が Hash になるwhere、グループを絞るのが havinghaving。Ruby 側の select で絞らないあとは count / minimum / maximum / sum / average を差し替えるだけで応用が効く。