Window 関数
集計しながら各行も保つ魔法のような機能。ランキング、累積、前後比較、移動平均、グループ内 N 件など。 SQL の表現力が一気に広がる。
普通の集計との違い
GROUP BY: Window:
+-----+-------+ +-----+-------+--------+
| user| total | | id | views | rank |
+-----+-------+ +-----+-------+--------+
| 1 | 500 | | 1 | 100 | 3 |
| 2 | 800 | | 2 | 300 | 2 |
+-----+-------+ | 3 | 500 | 1 |
+-----+-------+--------+
GROUP BY は行数が減る。Window は行数を保ったまま列を追加。
基本構文
window_function() OVER (
PARTITION BY ... -- グループ分け(GROUP BY 相当)
ORDER BY ... -- 順序
ROWS / RANGE ... -- 範囲指定
)
主な関数
ランキング系
- ROW_NUMBER() — 1, 2, 3, ...(同値も別番)
- RANK() — 1, 2, 2, 4, ...(同値はスキップ)
- DENSE_RANK() — 1, 2, 2, 3, ...(スキップなし)
- NTILE(n) — N グループに分ける
- PERCENT_RANK() — 0〜1 の相対順位
- CUME_DIST() — 累積分布
位置系
- LAG(col, n) — 前 n 行
- LEAD(col, n) — 後 n 行
- FIRST_VALUE(col) — グループ最初
- LAST_VALUE(col) — グループ最後(デフォルトの ROWS 範囲に注意)
- NTH_VALUE(col, n)
集計系(OVER 付き)
- SUM / AVG / COUNT / MIN / MAX / STRING_AGG など、すべて OVER と組み合わせ可
ROW_NUMBER の典型
-- 各ユーザの最新投稿だけ(DISTINCT ON の代替)
SELECT * FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM posts
) t
WHERE rn = 1;
各カテゴリの上位 3 件
SELECT * FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY views DESC) AS rn
FROM posts
) t
WHERE rn <= 3;
RANK / DENSE_RANK
SELECT
name, score,
RANK() OVER (ORDER BY score DESC) AS rank,
DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank
FROM scoreboard;
score RANK DENSE
100 1 1
90 2 2
90 2 2
80 4 3 ← RANK は 2,2 の次が 4、DENSE は 3
LAG / LEAD(前後比較)
-- 前日からの増減
SELECT
date,
views,
LAG(views) OVER (ORDER BY date) AS prev,
views - LAG(views) OVER (ORDER BY date) AS diff
FROM daily_stats;
第 2 引数で N 行前 + デフォルト値
LAG(views, 1, 0) OVER (...) -- 1 行前、無ければ 0
LEAD(views, 7, 0) OVER (...) -- 7 行後
累積
SELECT
date,
amount,
SUM(amount) OVER (ORDER BY date) AS cumulative
FROM revenue;
カテゴリ別累積
SUM(amount) OVER (PARTITION BY category ORDER BY date)
移動平均(ROWS)
-- 過去 7 日(自分含む)の平均
SELECT
date, sales,
AVG(sales) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS ma7
FROM daily;
ROWS の指定例
UNBOUNDED PRECEDING AND CURRENT ROW— 累計BETWEEN 1 PRECEDING AND 1 FOLLOWING— 前後 1 行BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING— 全範囲
ROWS vs RANGE
- ROWS: 物理的な行数
- RANGE: ORDER BY 値の差
RANGE の例
-- 過去 7 日(同日複数行も含む)
SUM(sales) OVER (
ORDER BY date
RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW
)
FIRST_VALUE / LAST_VALUE
SELECT
user_id, post_id, created_at,
FIRST_VALUE(post_id) OVER (PARTITION BY user_id ORDER BY created_at) AS first_post,
LAST_VALUE(post_id) OVER (
PARTITION BY user_id
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_post
FROM posts;
LAST_VALUE はデフォルトの範囲が「現在まで」なので、UNBOUNDED FOLLOWING を明示しないと最後にならない。
NTILE(N 分位)
SELECT
user_id, total_spent,
NTILE(4) OVER (ORDER BY total_spent DESC) AS quartile
FROM customer_stats;
-- 上位 25% / 50% / 75% / 100% に分割
WINDOW 句で名前付け
同じ Window 定義を何度も書かずに済む:
SELECT
user_id, created_at,
ROW_NUMBER() OVER w AS rn,
RANK() OVER w AS rank,
LAG(views) OVER w AS prev_views
FROM posts
WINDOW w AS (PARTITION BY user_id ORDER BY created_at);
典型パターン集
1. グループ内の最新 1 件
WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM events
)
SELECT * FROM ranked WHERE rn = 1;
2. 重複の検出と削除
-- email 重複の重複側だけを削除
DELETE FROM users WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
FROM users
) t WHERE rn > 1
);
3. 連続日数(ストリーク)
-- 連続ログイン日数を求める常套手段
SELECT user_id, COUNT(*) AS streak
FROM (
SELECT
user_id,
login_date,
login_date - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date))::INT AS grp
FROM logins
) t
GROUP BY user_id, grp
ORDER BY streak DESC;
4. 前回からの経過時間
SELECT
user_id, action_at,
action_at - LAG(action_at) OVER (PARTITION BY user_id ORDER BY action_at) AS gap
FROM actions;
5. 累計に対する比率
SELECT
category, name, sales,
sales * 1.0 / SUM(sales) OVER (PARTITION BY category) AS pct_in_cat
FROM products;
6. 上位 3 件 + 「その他」
WITH ranked AS (
SELECT category, sum(amount) AS total,
ROW_NUMBER() OVER (ORDER BY sum(amount) DESC) AS rn
FROM orders
GROUP BY category
)
SELECT
CASE WHEN rn <= 3 THEN category ELSE 'Other' END AS category,
sum(total) AS total
FROM ranked
GROUP BY 1;
7. 中央値
SELECT
category,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price) AS median
FROM products
GROUP BY category;
パフォーマンス
- OVER の ORDER BY 列にインデックスがあれば速い
- PARTITION + ORDER の組み合わせインデックスが理想
- 大規模データではメモリに展開されるので work_mem を確保
- EXPLAIN で WindowAgg ノードを確認
WAAPI 的な「ふるい」
Window 関数は、行を選別するときに WHERE で使えない。サブクエリ / CTE で囲んで rn = 1 のパターンが定番。
失敗パターン
| 症状 | 対処 |
|---|---|
| WHERE で Window 関数が使えない | サブクエリ / CTE に包む |
| LAST_VALUE が現在行の値しか返さない | ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
| RANK と DENSE_RANK の混乱 | 同点の扱いを明示的に |
| 遅い | PARTITION + ORDER のインデックス |
Window 関数の威力
GROUP BY だけでは「行が消える」のに対し、Window は残しながら計算できる。 これを覚えると SQL 一発で解ける問題が爆増する。レポート / ダッシュボード / リアルタイム分析の鍵。