集計と GROUP BY

COUNT / SUM / AVG / MIN / MAX、GROUP BY、HAVING、FILTER、ROLLUP / CUBE / GROUPING SETS まで。

主要な集計関数

関数意味
COUNT(*)全行数(NULL 含む)
COUNT(col)NULL 以外の行数
COUNT(DISTINCT col)ユニーク値の数
SUM(col)合計
AVG(col)平均
MIN(col) / MAX(col)最小・最大
STRING_AGG(col, ',')文字列連結(Postgres)
ARRAY_AGG(col)配列化
JSON_AGG(col)JSON 配列
BOOL_AND / BOOL_OR真偽集計
STDDEV / VARIANCE標準偏差・分散
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY col)中央値

基本(全体集計)

SELECT
  count(*)               AS total,
  count(DISTINCT user_id) AS unique_users,
  sum(views)             AS total_views,
  avg(views)             AS avg_views,
  max(views)             AS max_views
FROM posts;

GROUP BY

集計を「グループごと」に分割する:

SELECT
  user_id,
  count(*) AS post_count,
  avg(views) AS avg_views
FROM posts
GROUP BY user_id;

複数列で GROUP

SELECT
  status, date_trunc('month', created_at) AS month,
  count(*) AS posts
FROM posts
GROUP BY status, month
ORDER BY month, status;

GROUP BY と SELECT のルール

HAVING(集計後の WHERE)

WHERE は集計前、HAVING は集計後の絞り込み:

-- 投稿が 3 件以上のユーザ
SELECT user_id, count(*) AS post_count
FROM posts
GROUP BY user_id
HAVING count(*) >= 3;

WHERE と HAVING の使い分け

FILTER(条件付き集計)

Postgres / SQL 標準。1 クエリで複数の条件付きカウントが書ける:

SELECT
  count(*) AS total,
  count(*) FILTER (WHERE status = 'published') AS published,
  count(*) FILTER (WHERE status = 'draft')     AS draft,
  sum(views) FILTER (WHERE status = 'published') AS published_views
FROM posts;

CASE で代用も可だが FILTER の方が読みやすい:

-- CASE 版(互換性高い)
SELECT
  count(*) AS total,
  count(CASE WHEN status = 'published' THEN 1 END) AS published
FROM posts;

JOIN + GROUP BY の典型

SELECT
  u.id, u.email,
  count(p.id)             AS posts,
  count(c.id)             AS comments,
  max(p.created_at)       AS last_post
FROM users u
LEFT JOIN posts p    ON p.user_id = u.id
LEFT JOIN comments c ON c.user_id = u.id
GROUP BY u.id;

注意: 1 ユーザに対し posts × comments の組み合わせが出るので件数がズレる場合がある(直積問題)。 サブクエリ / CTE で個別集計するのが安全。

正しい書き方(個別集計)

SELECT
  u.id, u.email,
  (SELECT count(*) FROM posts    WHERE user_id = u.id) AS posts,
  (SELECT count(*) FROM comments WHERE user_id = u.id) AS comments
FROM users u;

累計 / 累積

集計のみだと「合計」しか取れない。累積を出すなら Window 関数(→ Window 関数)。

典型パターン集

1. 月別売上

SELECT
  date_trunc('month', created_at) AS month,
  sum(amount) AS revenue,
  count(*) AS orders
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY 1
ORDER BY 1;

2. 日別 DAU

SELECT
  date_trunc('day', logged_at) AS day,
  count(DISTINCT user_id) AS dau
FROM access_logs
WHERE logged_at >= NOW() - INTERVAL '30 days'
GROUP BY 1
ORDER BY 1;

3. 上位 N(カテゴリ別)

-- 各カテゴリの売上トップ 1 → ROW_NUMBER() の方が綺麗
-- 単純な「カテゴリ別 売上」なら:
SELECT category, sum(amount) AS sales
FROM orders
GROUP BY category
ORDER BY sales DESC
LIMIT 10;

4. ヒストグラム

SELECT
  width_bucket(views, 0, 1000, 10) AS bucket,
  count(*) AS posts,
  min(views), max(views)
FROM posts
GROUP BY 1
ORDER BY 1;

5. 文字列集約

-- 各ユーザのタグを CSV に
SELECT user_id, string_agg(tag, ', ' ORDER BY tag) AS tags
FROM user_tags
GROUP BY user_id;

6. 配列 / JSON 集約

SELECT user_id, array_agg(tag ORDER BY tag) AS tags FROM user_tags GROUP BY user_id;

SELECT
  p.id,
  json_agg(json_build_object('id', c.id, 'body', c.body) ORDER BY c.created_at) AS comments
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
GROUP BY p.id;

ROLLUP / CUBE / GROUPING SETS

小計 + 総合計を一度に出すための拡張:

ROLLUP

SELECT
  category, status, sum(amount)
FROM orders
GROUP BY ROLLUP (category, status);
-- (category, status) + (category) + () の集計

CUBE

SELECT category, status, sum(amount)
FROM orders
GROUP BY CUBE (category, status);
-- 全組み合わせ + 各単独 + 全体

GROUPING SETS

SELECT category, status, sum(amount)
FROM orders
GROUP BY GROUPING SETS (
  (category, status),
  (category),
  ()
);

GROUPING() 関数

SELECT
  CASE GROUPING(category) WHEN 1 THEN '合計' ELSE category END AS cat,
  sum(amount)
FROM orders
GROUP BY ROLLUP (category);

DISTINCT vs GROUP BY

NULL の扱い

パフォーマンス

失敗パターン

症状対処
SELECT 列がエラーGROUP BY に追加 or 集計関数で包む
JOIN で件数が膨らむサブクエリ別集計に分解
HAVING で絞れない集計関数の結果を使う
巨大テーブルで重いWHERE で先に絞る、マテビュー化
NULL が混じるCOALESCE / FILTER で対処
頻出パターン

date_trunc + GROUP BY 1 + ORDER BY 1」は時系列集計の決まり文句。 覚えておけば大半のレポートが書ける。