YASD-TECH
YASD TECH
# Rails

group と集計関数(GROUP BY・HAVING)

投稿日:2026/7/27

更新日:2026/7/27

ttitleImage

group と集計関数(GROUP BY・HAVING)

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

1. GROUP BY は「同じ値の行を1グループにまとめる」

元の 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グループになった。ここが出発点。


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 あり / なしの比較

この対比が 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)

3. where と having の違い

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(*) を書けない、と理解しておくと迷わない。

実用例: 「先月に初回注文した顧客」

wherehaving を組み合わせると、こういう条件が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。役割が違う。


4. 落とし穴

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)

複数カラムで group するとキーが配列になる

Order.group(:customer_id, :status).count
# => { [1, "paid"] => 2, [2, "pending"] => 1 }

group した列と集計関数以外は SELECT できない

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 では取れない

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 すると行が増える

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

5. 練習

# 顧客ごとの注文件数
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、グループを絞るのが having
  • 集計値で絞りたいときは having。Ruby 側の select で絞らない

あとは count / minimum / maximum / sum / average を差し替えるだけで応用が効く。

Index

  • group と集計関数(GROUP BY・HAVING)
  • 1. GROUP BY は「同じ値の行を1グループにまとめる」
  • 2. まとめた後は「集計関数」でしか中身を取り出せない
  • group あり / なしの比較
  • 3. where と having の違い
  • 実行順を意識する
  • 実用例: 「先月に初回注文した顧客」
  • 4. 落とし穴
  • having に SELECT の別名は使えないことがある
  • 複数カラムで group するとキーが配列になる
  • group した列と集計関数以外は SELECT できない
  • 「代表する行そのもの」は group では取れない
  • group(...).count の後は Hash(=Ruby側)
  • joins すると行が増える
  • 5. 練習
  • まとめ