「ウィンドウ関数って、なんか難しそう……」
この言葉を何度聞いただろう。SQLをある程度書けるようになったエンジニアやアナリストが、次のステップとして壁にぶつかる場所が「ウィンドウ関数」だ。
しかし一度体に染み込んでしまえば、ウィンドウ関数は「魔法」に変わる。GROUP BYでは不可能だったことが、数行で書けるようになる。複数のサブクエリに分解していた分析が、1つのクエリで完結する。
SQLの求人市場でも、その価値は明確に示されている。データエンジニアの求人の79.4%がSQLスキルを要求しており(365 Data Science 2024年調査)、データアナリストの求人の52.9%でSQLが最重要スキルとして明記されている(365 Data Science 2024年調査)。そして「高度なSQLスキル」の筆頭に挙げられるのが、ウィンドウ関数だ。
米国労働統計局は2024年から2034年にかけてデータ専門職の求人が34%増加すると予測しており、その成長の恩恵を最も受けるのは「ウィンドウ関数を使いこなせる」エンジニアとアナリストだ(Coursera / BLS)
この記事では、現場で本当によく使う7つのウィンドウ関数のパターンを、マーケティングデータを例に完全解説する。
ウィンドウ関数とは何か
まず核心から入る。
GROUP BYとウィンドウ関数の根本的な違いは「行を集約するかどうか」だ。
-- GROUP BY:行が集約される(顧客ごとに1行になる)
SELECT
customer_id,
SUM(amount) AS total_amount
FROM orders
GROUP BY customer_id;
-- 結果:顧客ごとに1行
-- ウィンドウ関数:元の行数を保ちながら集計値を追加できる
SELECT
customer_id,
order_id,
order_date,
amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total -- 顧客合計を各行に付加
FROM orders;
-- 結果:全注文が行として残り、各行に顧客合計が追加されるGROUP BYは「まとめる」。ウィンドウ関数は「まとめずに、各行に計算結果を添える」。
この違いが「GROUP BYでは不可能だった分析」を可能にする。例えば「各注文が、その顧客の全注文の何%を占めるか」
GROUP BYだけでは複数のステップが必要だが、ウィンドウ関数なら1つのクエリで書ける。
ウィンドウ関数の基本構文
-- 関数名(引数) OVER (
-- PARTITION BY 集計のグループ化キー -- ← 省略可:全行を1ウィンドウとして扱う
-- ORDER BY 並び順 -- ← 省略可:順位やランキング系で必要
-- ROWS/RANGE BETWEEN ... AND ... -- ← 省略可:移動平均などで使う
-- )
SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date)
-- └─ 顧客ごとに ─┘└─ 注文日順に累計 ─┘では7つのパターンを順番に解説しよう。
パターン①:ROW_NUMBER — 「各グループ内での行番号」
使いどころ:最新レコードの取得、重複除去
「各顧客の最新注文だけを取得したい」このニーズはマーケティングの現場で頻繁に発生する。
GROUP BYで集計するだけでは「最新注文の詳細情報(商品カテゴリ・注文金額・配送先など)」は取れない。ここで ROW_NUMBER() が活きる。
-- 各顧客の「最新注文」だけを取得する
WITH ranked_orders AS (
SELECT
customer_id,
order_id,
order_date,
amount,
product_category,
-- 顧客ごとに注文日の新しい順で番号を振る
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date DESC, order_id DESC -- 同日なら新しいIDを優先
) AS rn
FROM orders
WHERE status = 'completed'
)
SELECT
customer_id,
order_id,
order_date AS last_order_date,
amount AS last_order_amount,
product_category AS last_category
FROM ranked_orders
WHERE rn = 1; -- 各顧客の1番目(最新)だけ取り出すROW_NUMBER vs RANK vs DENSE_RANK の違い
同じ「順位付け」でも3つの関数で挙動が異なる。
-- 顧客別購買金額ランキング(同額がある場合の違い)
SELECT
customer_id,
total_amount,
ROW_NUMBER() OVER (ORDER BY total_amount DESC) AS row_num,
-- → 同額でも連番(同額は処理順で1位・2位)
RANK() OVER (ORDER BY total_amount DESC) AS rank_num,
-- → 同額は同順位(1位・1位・3位)
DENSE_RANK() OVER (ORDER BY total_amount DESC) AS dense_rank_num
-- → 同額は同順位だが次の順位は連続(1位・1位・2位)
FROM customer_ltv;
-- 出力例:
-- customer_id | total_amount | row_num | rank_num | dense_rank_num
-- C001 | 50,000 | 1 | 1 | 1
-- C002 | 50,000 | 2 | 1 | 1 ← 同額
-- C003 | 30,000 | 3 | 3 | 2 ← RANKは3位、DENSE_RANKは2位使い分けの原則
- 「重複なく1件だけ取りたい」→
ROW_NUMBER() - 「同率を正確に表現したい(オリンピックの順位表示)」→
RANK() - 「順位の飛びをなくしたい」→
DENSE_RANK()
パターン②:SUM OVER — 「累計(ランニングトータル)」
使いどころ:月次累計売上、コホート別累計購買額
「今月の日別累計売上グラフを作りたい」
これが SUM OVER ORDER BY の典型的なユースケースだ。
-- 日別売上の累計グラフ用データ
SELECT
order_date,
daily_revenue,
-- ORDER BY order_date を指定することで「その日までの累計」になる
SUM(daily_revenue) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_revenue,
-- 先月同日比(前月の同じ日までの累計)も同時に出す
SUM(daily_revenue) OVER (
ORDER BY order_date
) AS running_total
FROM (
SELECT
order_date,
SUM(amount) AS daily_revenue
FROM orders
WHERE order_date >= DATE_TRUNC('month', CURRENT_DATE)
AND status = 'completed'
GROUP BY order_date
) daily_agg
ORDER BY order_date;ROWS BETWEEN の指定パターン
-- 移動平均(直近7日間)
SUM(daily_revenue) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW -- 今日を含む直近7日間
)
-- 全期間累計
SUM(daily_revenue) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
-- 全グループ合計(ORDER BYなし = ウィンドウが全行)
SUM(amount) OVER (PARTITION BY customer_id)パターン③:LAG / LEAD — 「前後の行を参照する」
使いどころ:前月比、購買間隔の計算
「各購買と、その前回の購買の日数差(購買間隔)を計算したい」これが LAG() の真骨頂だ。
-- 顧客ごとの購買間隔を計算する
SELECT
customer_id,
order_id,
order_date,
amount,
-- 1つ前の注文日を取得
LAG(order_date, 1) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS prev_order_date,
-- 前回注文からの経過日数
DATE_DIFF(
'day',
LAG(order_date, 1) OVER (
PARTITION BY customer_id
ORDER BY order_date
),
order_date
) AS days_since_last_order,
-- 次の注文日(先行き)も見たい場合はLEAD
LEAD(order_date, 1) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS next_order_date
FROM orders
WHERE status = 'completed'
ORDER BY customer_id, order_date;前月比の計算への応用
-- 月別売上の前月比を計算する
WITH monthly_sales AS (
SELECT
DATE_TRUNC('month', order_date) AS month,
SUM(amount) AS monthly_revenue
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', order_date)
)
SELECT
month,
monthly_revenue,
LAG(monthly_revenue, 1) OVER (ORDER BY month) AS prev_month_revenue,
-- 前月比(%)
ROUND(
100.0 * (monthly_revenue - LAG(monthly_revenue, 1) OVER (ORDER BY month))
/ NULLIF(LAG(monthly_revenue, 1) OVER (ORDER BY month), 0)
, 1) AS mom_growth_pct
FROM monthly_sales
ORDER BY month DESC;
-- 出力例:
-- month | monthly_revenue | prev_month_revenue | mom_growth_pct
-- 2024-12 | 12,500,000 | 10,800,000 | +15.7
-- 2024-11 | 10,800,000 | 11,200,000 | -3.6
-- 2024-10 | 11,200,000 | 9,600,000 | +16.7パターン④:NTILE — 「均等分割(RFMスコアリング)」
使いどころ:RFM分析、パーセンタイル計算
RFM分析のスコアリングに NTILE は欠かせない。全顧客を「等分割してランクを振る」のが NTILE(N) だ。
-- RFMスコアリング(SQL道場#01でも詳しく解説)
WITH rfm_raw AS (
SELECT
customer_id,
DATE_DIFF('day', MAX(order_date), DATE '2024-12-31') AS recency_days,
COUNT(order_id) AS frequency,
SUM(amount) AS monetary
FROM orders
WHERE
status = 'completed'
AND order_date BETWEEN DATE '2024-01-01' AND DATE '2024-12-31'
GROUP BY customer_id
)
SELECT
customer_id,
recency_days,
frequency,
monetary,
-- R:経過日数が少ない(最近買った)ほど高スコア → 降順でNTILE
NTILE(5) OVER (ORDER BY recency_days DESC) AS r_score,
-- F:回数が多いほど高スコア → 昇順でNTILE
NTILE(5) OVER (ORDER BY frequency ASC) AS f_score,
-- M:金額が高いほど高スコア → 昇順でNTILE
NTILE(5) OVER (ORDER BY monetary ASC) AS m_score
FROM rfm_raw;NTILE の注意点
顧客数が少ない場合(500人以下程度)は、NTILEの分割が偏る。
例:101人をNTILE(5)で分割すると、1〜5グループの人数が「21, 20, 20, 20, 20」になる。グループ1だけ1人多い。これはNTILEの仕様で、「余りは前のグループから順に1人ずつ追加される」からだ。
小規模データでは NTILE(3) の3分割を検討するか、パーセンタイルで計算する方が正確なことがある。
パターン⑤:FIRST_VALUE / LAST_VALUE — 「グループ内の最初・最後の値を参照する」
使いどころ:初回購買カテゴリの追跡、最初の接触チャネル特定
「各顧客の最初の購買カテゴリ(入口カテゴリ)は何か」
これがリテンション分析や商品設計において非常に重要な問いだ。
-- 顧客の「入口カテゴリ」と「最新カテゴリ」を同時に把握する
SELECT DISTINCT
customer_id,
-- 最初の購買カテゴリ(入口)
FIRST_VALUE(product_category) OVER (
PARTITION BY customer_id
ORDER BY order_date ASC, order_id ASC
) AS first_category,
-- 最新の購買カテゴリ
LAST_VALUE(product_category) OVER (
PARTITION BY customer_id
ORDER BY order_date ASC, order_id ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
-- ← LAST_VALUE は ROWS BETWEEN の明示指定が必要(デフォルトが現在行まで)
) AS latest_category,
-- 総購買回数
COUNT(order_id) OVER (PARTITION BY customer_id) AS total_orders
FROM orders
WHERE status = 'completed'
ORDER BY customer_id;LAST_VALUE の「罠」に注意
LAST_VALUE はデフォルトのウィンドウ範囲が「ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW」(先頭から現在行まで)なので、最後の行まで含めるには明示的に ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING を指定する必要がある。
これを知らずに LAST_VALUE を使うと、「現在行の値」が返ってしまうことがある。これは初心者がよくハマる罠だ。
パターン⑥:COUNT OVER — 「グループ内の件数と割合を同時に出す」
使いどころ:セグメント別の比率計算、カテゴリ別の構成比
「各セグメントの顧客数と、全体に占める割合を1つのクエリで出したい」これが COUNT OVER の典型的な使い方だ。
-- セグメント別の顧客数と全体比率を1クエリで算出
SELECT
customer_segment,
COUNT(customer_id) AS segment_count,
-- 全顧客数(OVERに何も指定しない = 全行が対象)
COUNT(customer_id) OVER () AS total_customers,
-- セグメント内の顧客数 ÷ 全顧客数 = 構成比
ROUND(
100.0 * COUNT(customer_id)
/ COUNT(customer_id) OVER ()
, 1) AS pct_of_total
FROM customer_segments
GROUP BY customer_segment
ORDER BY segment_count DESC;
-- 出力例:
-- customer_segment | segment_count | total_customers | pct_of_total
-- 休眠顧客 | 42,150 | 125,000 | 33.7
-- 優良顧客 | 28,750 | 125,000 | 23.0
-- 新規育成中 | 19,300 | 125,000 | 15.4
-- VIPロイヤル顧客 | 11,250 | 125,000 | 9.0
-- その他 | 23,550 | 125,000 | 18.9応用:パーセンタイルを使ったLTV分布分析
-- LTVの分布を把握する(上位10%・25%・50%ラインを確認)
SELECT
customer_id,
total_ltv,
ROUND(
100.0 * PERCENT_RANK() OVER (ORDER BY total_ltv ASC)
, 1) AS ltv_percentile,
NTILE(10) OVER (ORDER BY total_ltv ASC) AS ltv_decile, -- 10分位
CASE
WHEN PERCENT_RANK() OVER (ORDER BY total_ltv ASC) >= 0.9
THEN '上位10%(高LTV顧客)'
WHEN PERCENT_RANK() OVER (ORDER BY total_ltv ASC) >= 0.75
THEN '上位25%(準高LTV顧客)'
WHEN PERCENT_RANK() OVER (ORDER BY total_ltv ASC) >= 0.5
THEN '上位50%(中LTV顧客)'
ELSE '下位50%(低LTV顧客)'
END AS ltv_tier
FROM customer_ltv
ORDER BY total_ltv DESC;パターン⑦:SUM / AVG OVER ORDER BY — 「移動平均と累計の組み合わせ」
使いどころ:7日移動平均、コホート別の累積リテンション
ウィンドウ関数の応用として最も高度なのが「移動平均」だ。日次データのノイズを除去して「トレンドを可視化」するのに欠かせない。
-- 7日移動平均売上(直近7日間の平均)
WITH daily_sales AS (
SELECT
order_date,
SUM(amount) AS daily_revenue
FROM orders
WHERE
status = 'completed'
AND order_date >= DATE_ADD('day', -90, CURRENT_DATE)
GROUP BY order_date
)
SELECT
order_date,
daily_revenue,
-- 7日移動平均(今日を含む直近7日間の平均)
ROUND(
AVG(daily_revenue) OVER (
ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)
, 0) AS revenue_7day_ma,
-- 30日移動平均(季節性を除去した月次トレンド)
ROUND(
AVG(daily_revenue) OVER (
ORDER BY order_date
ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
)
, 0) AS revenue_30day_ma
FROM daily_sales
ORDER BY order_date;コホート別の累積リテンション分析
これが最も複雑だが、最も価値あるウィンドウ関数の応用だ。
-- コホート別の月次リテンション率を算出する
WITH first_purchase AS (
-- 各顧客の初回購買月(コホート定義)
SELECT
customer_id,
DATE_TRUNC('month', MIN(order_date)) AS cohort_month
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
),
monthly_activity AS (
-- 各顧客の月別購買有無
SELECT DISTINCT
o.customer_id,
DATE_TRUNC('month', o.order_date) AS activity_month
FROM orders o
WHERE o.status = 'completed'
),
cohort_activity AS (
SELECT
fp.cohort_month,
ma.activity_month,
COUNT(DISTINCT ma.customer_id) AS active_customers,
-- コホートの初月の人数(ウィンドウ関数で取得)
FIRST_VALUE(COUNT(DISTINCT ma.customer_id)) OVER (
PARTITION BY fp.cohort_month
ORDER BY ma.activity_month ASC
) AS cohort_size,
-- 初月からの経過月数
DATE_DIFF(
'month', fp.cohort_month, ma.activity_month
) AS months_since_first
FROM first_purchase fp
JOIN monthly_activity ma
ON fp.customer_id = ma.customer_id
GROUP BY fp.cohort_month, ma.activity_month
)
SELECT
cohort_month,
months_since_first,
active_customers,
cohort_size,
ROUND(100.0 * active_customers / cohort_size, 1) AS retention_rate_pct
FROM cohort_activity
ORDER BY cohort_month, months_since_first;まとめ:7つのパターンの使い分けチートシート
ウィンドウ関数 使い分けチートシート─────────────────────────────────────────────────────■ 順位・番号系ROW_NUMBER() → 重複なし連番(最新1件の取得・重複除去)RANK() → 同率は同順位(次の順位が飛ぶ)DENSE_RANK() → 同率は同順位(順位が連続する)NTILE(N) → N等分してグループ番号を振る(RFMスコア)■ 集計・比率系SUM OVER → 累計・グループ合計・移動合計AVG OVER → 移動平均・グループ平均COUNT OVER → グループ件数・全体比率の同時計算PERCENT_RANK()→ パーセンタイルランク(LTVの上位X%)■ 行参照系LAG(col, N) → N行前の値を参照(前月比・購買間隔)LEAD(col, N) → N行後の値を参照(次回購買予測)FIRST_VALUE() → ウィンドウ内の最初の値(入口カテゴリ)LAST_VALUE() → ウィンドウ内の最後の値(最新状態) ※ROWS BETWEEN の明示指定が必要─────────────────────────────────────────────────────
Treasure Data(Presto)でのウィンドウ関数:注意点
Treasure DataはPrestoエンジンを使っているため、標準的なウィンドウ関数は全て動く。ただし以下の点に注意
① TD_TIME_RANGE との組み合わせ
-- TDのUNIXタイム(time列)と組み合わせる場合
SELECT
customer_id,
TD_TIME_FORMAT(time, 'yyyy-MM-dd', 'JST') AS order_date,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY time -- UNIXタイムで並び替えるとPRECISEになる
) AS cumulative_amount
FROM td_orders
WHERE TD_TIME_RANGE(time, '2024-01-01 00:00:00 JST', '2024-12-31 23:59:59 JST')② 大量データでのウィンドウ関数はメモリを消費する
数億件のテーブルでウィンドウ関数(特にORDER BY付き)を使うと、メモリ不足(Spillage)が発生することがある。
- まずサブクエリで対象データを絞り込んでからウィンドウ関数を適用
- CTAS(CREATE TABLE AS SELECT)で中間テーブルを作ってからウィンドウ処理
- PrestoをHiveに切り替えてバッチ処理
③ LAST_VALUE の RANGE/ROWS BETWEEN の指定
前述の通り、LAST_VALUE は必ず ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING を明示する。これはPrestoでも同様だ。
「ウィンドウ関数を使いこなせる人材」の市場価値
データエンジニアの求人の79.4%がSQLスキルを要求し、データアナリストの求人の52.9%でSQLが最重要スキルとして挙げられている今(365 Data Science 2025年調査)、「SQLが書ける」だけでは差別化にならない時代に入っている。
差別化になるのは「複雑なビジネス問題をSQLで解ける力」
そしてその力の多くは、ウィンドウ関数から生まれる。
「各顧客の購買間隔が縮まっているか広がっているかをトレンドで見たい」「新規顧客のコホート別リテンションが月次でどう変化しているかを可視化したい」「RFMスコアリングを毎月自動更新したい」
これらはウィンドウ関数なしでは何十行もかかるか、そもそも1クエリでは書けない。
ウィンドウ関数は難しくない。「GROUP BYとの違い」を理解し、7つのパターンを実際のデータで手を動かして練習すれば、2〜3週間で実用レベルに達する。
ウィンドウ関数を制する者が、SQLを制す。 そしてSQLを制する者が、データを制す。
参考文献・出典
| # | 出典 | URL |
|---|---|---|
| 1 | 365 Data Science「Data Engineer Job Outlook 2025」SQLが求人の79.4%で必須 | https://365datascience.com/career-advice/data-engineer-job-outlook-2025/ |
| 2 | 365 Data Science「Data Analyst Job Market 2024」SQLが求人の52.9%で最重要スキル | https://365datascience.com/career-advice/the-data-analyst-job-market/ |
| 3 | Coursera / BLS「データ専門職の求人が2024〜2034年で34%増加予測」 | https://www.coursera.org/articles/in-demand-data-analyst-skills-to-get-hired |
| 4 | Treasure Data公式ドキュメント(Prestoエンジンのウィンドウ関数仕様) | https://docs.treasuredata.com |
| 5 | MarTech Farm「#01│RFM分析をSQLだけで完結させる」(NTILE活用事例) | https://martechfarm.com/2026/04/04/sql-recipes-1/ |
© MarTech Farm. All rights reserved.