LAG() / LEAD() — 前後の行の値を参照して「前月比・増減」を計算する
LAG(列名, n) は「n行前」の値を、LEAD(列名, n) は「n行後」の値を現在行に引き込む関数です。どちらも OVER(ORDER BY ...) で行の並び順を指定します。
SELECT month, revenue, LAG(revenue, 1) OVER(ORDER BY month) AS prev_revenue, -- 1行前 LEAD(revenue, 1) OVER(ORDER BY month) AS next_revenue -- 1行後 FROM monthly_revenue;
LAG(revenue, 2) とすると「2行前」が参照できます。省略時はデフォルト 1(直前行)です。第3引数にデフォルト値を指定でき、前の行が存在しない場合に NULL の代わりにその値が返ります(例: LAG(revenue, 1, 0))。以下の monthly_revenue テーブルを使い、各月の売上とともに「前月売上(prev_revenue)」「翌月売上(next_revenue)」「前月比増減額(diff_from_prev)」を出力してください。
前後の月が存在しない場合は NULL になることを確認してください。
| month | revenue |
|---|---|
| 2024-01 | 400000 |
| 2024-02 | 460000 |
| 2024-03 | 430000 |
| 2024-04 | 510000 |
| 2024-05 | 480000 |
| month | revenue | prev_revenue | next_revenue | diff_from_prev |
|---|---|---|---|---|
| 2024-01 | 400,000 | NULL | 460,000 | NULL |
| 2024-02 | 460,000 | 400,000 | 430,000 | +60,000 |
| 2024-03 | 430,000 | 460,000 | 510,000 | -30,000 |
| 2024-04 | 510,000 | 430,000 | 480,000 | +80,000 |
| 2024-05 | 480,000 | 510,000 | NULL | -30,000 |
SELECT month, revenue, LAG(revenue) OVER(ORDER BY month) AS prev_revenue, -- 1行前の revenue(先頭行は NULL) LEAD(revenue) OVER(ORDER BY month) AS next_revenue, -- 1行後の revenue(最終行は NULL) revenue - LAG(revenue) OVER(ORDER BY month) AS diff_from_prev -- 今月 − 前月(先頭行は NULL) FROM monthly_revenue ORDER BY month; /* 実行順序: 1. FROM monthly_revenue → 5行取得 2. Window関数 (LAG/LEAD) → 前後の revenue を付与 3. diff_from_prev → 前月差を計算 4. SELECT 出力 → 列を出力 5. ORDER BY month → 昇順に並べ替え */
LEGEND
① FROM
FROM monthly_revenuemonthly_revenue テーブル全体(5行)を読み込みます。| month | revenue |
|---|---|
| 2024-01 | 400,000 |
| 2024-02 | 460,000 |
| 2024-03 | 430,000 |
| 2024-04 | 510,000 |
| 2024-05 | 480,000 |
t1.revenue - t2.revenue のような自己JOINが一般的でした。LAG は同じ前後行参照をクエリ上で直接表現でき、物理的な実行方法はオプティマイザが決定します。実務でのKPI推移計算・ダッシュボード用クエリには必須の関数です。LAG(revenue, 1, 0) とすると、前の行が存在しない先頭行に 0 が入ります。「最初の月は前月比 0 として扱いたい」などの要件では第3引数を活用しましょう。LAG(revenue) OVER(PARTITION BY dept ORDER BY month) とすれば、「部署ごとの前月比」が計算できます。部署が変わると参照ウィンドウがリセットされるため、別の部署の前月を参照してしまうミスが防げます。| month | revenue | LAG(1行前) | LEAD(1行後) |
|---|---|---|---|
| 2024-01 | 400,000 | — | — |
| ⇧ 2024-02 | 460,000 | ▲ LAGが参照する行 | — |
| ▶ 2024-03 (現在行) | 430,000 | 460,000 | 510,000 |
| ⇩ 2024-04 | 510,000 | — | ▼ LEADが参照する行 |
| 2024-05 | 480,000 | — | — |
LAG(revenue) OVER() のように ORDER BY を書かないと、エンジンが任意の順序で行を評価するため「前の行」が不定になります。LAG/LEAD は必ず OVER(ORDER BY 並び順を決める列) とセットで使ってください。NTILE() — 顧客を購入金額で四分位(Quartile)に分類する
NTILE(n) は、ORDER BY で並べたデータを n 個の均等なバケツ(桶)に分割し、各行に 1〜n のバケツ番号を付与する関数です。
NTILE(4) OVER(ORDER BY amount DESC) AS quartile -- 全行を amount 降順で並べ、4分割した場合の桶番号(1〜4)を付与する
以下の customers テーブル(8名の顧客と購入合計額)を使い、total_amount が高い順に 4等分(NTILE(4))して quartile(四分位)番号を付与してください。
quartile=1 が最高購入額グループ(上位25%)、quartile=4 が最低購入額グループです。
| customer_id | name | total_amount |
|---|---|---|
| C1 | 田中 | 85,000 |
| C2 | 佐藤 | 42,000 |
| C3 | 鈴木 | 120,000 |
| C4 | 高橋 | 30,000 |
| C5 | 伊藤 | 75,000 |
| C6 | 渡辺 | 98,000 |
| C7 | 中村 | 15,000 |
| C8 | 小林 | 55,000 |
| customer_id | name | total_amount | quartile |
|---|---|---|---|
| C3 | 鈴木 | 120,000 | 1 |
| C6 | 渡辺 | 98,000 | 1 |
| C1 | 田中 | 85,000 | 2 |
| C5 | 伊藤 | 75,000 | 2 |
| C8 | 小林 | 55,000 | 3 |
| C2 | 佐藤 | 42,000 | 3 |
| C4 | 高橋 | 30,000 | 4 |
| C7 | 中村 | 15,000 | 4 |
SELECT customer_id, name, total_amount, NTILE(4) OVER(ORDER BY total_amount DESC) AS quartile -- 降順で4等分(各2行・1=最高額VIP) FROM customers ORDER BY total_amount DESC; /* 実行順序: 1. FROM customers → 8行取得 2. Window関数 NTILE(4) → total_amount DESC で4分割し帯番号付与 3. SELECT 出力 → 列を出力 4. ORDER BY total_amount DESC → 最終整列 */
LEGEND
① FROM
FROM customerscustomers テーブル全体(8行)を読み込みます。この時点ではランダムな順序です。| customer_id | name | total_amount |
|---|---|---|
| C1 | 田中 | 85,000 |
| C2 | 佐藤 | 42,000 |
| C3 | 鈴木 | 120,000 |
| C4 | 高橋 | 30,000 |
| C5 | 伊藤 | 75,000 |
| C6 | 渡辺 | 98,000 |
| C7 | 中村 | 15,000 |
| C8 | 小林 | 55,000 |
NTILE(4) OVER(PARTITION BY region ORDER BY amount DESC) とすれば、「地域ごとに四分位」を計算できます。全社横断ではなく「東日本の中でのVIP」などの相対評価に使います。NTILE(10) を使った「デシル分析」が頻出です。顧客を購入金額で10等分し、上位10%(デシル1)への施策とそれ以外への施策を分けます。| 行 | total_amount | quartile (NTILE=4) | 備考 |
|---|---|---|---|
| 1位 | 120,000 | 1 | 余り1行 → バケツ1に追加 |
| 2位 | 98,000 | 1 | |
| 3位 | 90,000 | 1 ← 3行 | ⇦ 余りがここに入る |
| 4位 | 85,000 | 2 | |
| 5位 | 75,000 | 2 ← 2行 | |
| 6位 | 55,000 | 3 | |
| 7位 | 42,000 | 3 ← 2行 | |
| 8位 | 30,000 | 4 | |
| 9位 | 15,000 | 4 ← 2行 |
PERCENTILE_CONT 関数を使います。ORDER BY total_amount DESC, customer_id のようにタイブレーカーを追加してください。ROWS BETWEEN — ウィンドウフレームを指定して「直近3日間移動平均」を計算する
OVER句の中に ROWS BETWEEN 開始 AND 終了を書くと、「どの行の範囲を集計対象とするか(フレーム)」を細かく指定できます。これがウィンドウ関数の最も強力な機能です。
AVG(temp) OVER( ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- 2行前から現在行まで = 直近3行(このデータでは連続する3日間) )
•
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — 先頭行〜現在行(累計)•
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW — 2行前〜現在行(直近3行)•
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING — 全行(パーティション全体)
ROWS は物理的な行数でフレームを決めます。RANGE は値の範囲でフレームを決めます(同じ日付が複数行あると、RANGE はそれらをまとめてフレームに含めます)。移動平均など物理行数基準の計算には ROWS を使うのが安全です。以下の daily_temperature テーブル(1暦日につき1行で日付に欠落なし)から、「当日を含む直近3日間の気温移動平均(moving_avg_3d)」を求めてください。小数点第1位まで出力すること。このデータでは直近3行が直近3日間に一致します。
最初の2行は3日分揃っていないため、揃っている分だけで平均を計算します(出力例参照)。
| measured_date | temp |
|---|---|
| 2024-07-01 | 28.5 |
| 2024-07-02 | 31.2 |
| 2024-07-03 | 33.0 |
| 2024-07-04 | 29.8 |
| 2024-07-05 | 32.1 |
| 2024-07-06 | 35.4 |
| measured_date | temp | moving_avg_3d |
|---|---|---|
| 2024-07-01 | 28.5 | 28.5 |
| 2024-07-02 | 31.2 | 29.9 |
| 2024-07-03 | 33.0 | 30.9 |
| 2024-07-04 | 29.8 | 31.3 |
| 2024-07-05 | 32.1 | 31.6 |
| 2024-07-06 | 35.4 | 32.4 |
SELECT measured_date, temp, ROUND( AVG(temp) OVER( ORDER BY measured_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- 2行前〜現在の3行フレーム(先頭は不足分で平均) ) , 1) AS moving_avg_3d -- ROUND(..., 1): 小数点第1位に丸める FROM daily_temperature ORDER BY measured_date; /* 実行順序: 1. FROM daily_temperature → 6行取得 2. Window関数 → フレームで移動平均を計算 3. ROUND → 小数第1位に丸め 4. SELECT 出力 → 列を出力 */
LEGEND
① FROM
FROM daily_temperaturedaily_temperature テーブル全体(6行)を読み込みます。| measured_date | temp |
|---|---|
| 07-01 | 28.5 |
| 07-02 | 31.2 |
| 07-03 | 33.0 |
| 07-04 | 29.8 |
| 07-05 | 32.1 |
| 07-06 | 35.4 |
RANGE は「行数」ではなく ORDER BY 値の差を基準とし、日付オフセットの正確な構文はDB方言ごとに異なります。同じ日付の行が複数ある場合はピア行をまとめて含めます。「直近N行」が要件なら ROWS を使い、日付の欠落や同日複数行があり得るデータで「暦上の期間」が要件ならDB方言に合う日付 interval を使いましょう。PARTITION BY product_id ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW で「商品ごとの直近7行移動平均」が計算できます。1暦日につき1行で日付に欠落がなければ7日間移動平均と一致します。パーティション境界をまたいで計算されることはありません。| measured_date | temp | 集計フレーム内の行(青=現在行、薄=過去行) | avg |
|---|---|---|---|
| 07-01 | 28.5 | 28.5 | 28.5 |
| 07-02 | 31.2 | 28.5 + 31.2 | 29.9 |
| 07-03 | 33.0 | 28.5 + 31.2 + 33.0 | 30.9 |
| 07-04 | 29.8 | 31.2 + 33.0 + 29.8 ← 07-01が脱落 | 31.3 |
AVG(temp) OVER(ORDER BY date) と書いた場合、一般的なデフォルトフレームは同じ ORDER BY 値のピア行も含む RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW です。これは固定幅の「移動平均」ではなく「累計平均(先頭から現在の値まで)」です。移動平均を計算したいなら、必ず ROWS BETWEEN N PRECEDING AND CURRENT ROW を明示してください。FIRST_VALUE() / LAST_VALUE() — 期間の「初値」と「終値」を全行に付与する
FIRST_VALUE(列名) はウィンドウ(フレーム)内の最初の行の値を、LAST_VALUE(列名) は最後の行の値を返します。
FIRST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_price
LAST_VALUE で ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING のような全パーティションのフレームを指定しないと、ORDER BY 付きの一般的なデフォルトフレームは「先頭から現在行と同じ ORDER BY 値を持つピア行まで」です。この例では日付が一意なので現在行がフレームの最後となり、LAST_VALUE は現在行の値を返してしまいます。これはSQLの中で最も有名なバグの一つです。以下の stock_prices テーブル(2銘柄の株価)から、銘柄ごとに「期間初値(first_price)」と「期間終値(last_price)」をすべての行に付与してください。
※ LAST_VALUE を使うときは ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING が必須であることを意識してください。
| stock | trade_date | price |
|---|---|---|
| TYK | 2024-04-01 | 1,200 |
| TYK | 2024-04-02 | 1,150 |
| TYK | 2024-04-03 | 1,280 |
| OSK | 2024-04-01 | 850 |
| OSK | 2024-04-02 | 920 |
| OSK | 2024-04-03 | 890 |
| stock | trade_date | price | first_price | last_price |
|---|---|---|---|---|
| OSK | 2024-04-01 | 850 | 850 | 890 |
| OSK | 2024-04-02 | 920 | 850 | 890 |
| OSK | 2024-04-03 | 890 | 850 | 890 |
| TYK | 2024-04-01 | 1,200 | 1,200 | 1,280 |
| TYK | 2024-04-02 | 1,150 | 1,200 | 1,280 |
| TYK | 2024-04-03 | 1,280 | 1,200 | 1,280 |
SELECT stock, trade_date, price, FIRST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING -- 全行フレーム(LAST_VALUE と対称に明示) ) AS first_price, LAST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING -- ★全行フレーム必須(省略すると現在のピア行まで) ) AS last_price FROM stock_prices ORDER BY stock, trade_date; /* 実行順序: 1. FROM stock_prices → 6行取得 2. PARTITION BY stock → TYK(3行)とOSK(3行)に分割 3. 各パーティション内で ORDER BY trade_date で昇順に並べる 4. FIRST_VALUE → パーティション内の最初の行の price を全行に付与 5. LAST_VALUE → UNBOUNDED FOLLOWING指定により、 パーティション内の最後の行の price を全行に付与する 6. SELECT 出力 7. ORDER BY stock, trade_date で整列 */
LEGEND
① FROM
FROM stock_pricesstock_prices テーブル全体(6行)を読み込みます。| stock | trade_date | price |
|---|---|---|
| TYK | 04-01 | 1,200 |
| TYK | 04-02 | 1,150 |
| TYK | 04-03 | 1,280 |
| OSK | 04-01 | 850 |
| OSK | 04-02 | 920 |
| OSK | 04-03 | 890 |
MAX(price) OVER(PARTITION BY stock) は「銘柄内の最高値を全行に付与」します。「最初・最後の値」ではなく「最大・最小の値」が欲しい場合は MAX/MIN の方がシンプルで LAST_VALUE の罠もありません。LAST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date -- フレーム省略! -- 一般的なデフォルト: 先頭〜現在のピア行 )
| trade_date | price | last_price (バグ) |
|---|---|---|
| 04-01 | 1,200 | 1,200 ← 自分自身! |
| 04-02 | 1,150 | 1,150 ← 自分自身! |
| 04-03 | 1,280 | 1,280 ← 自分自身! |
LAST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING )
| trade_date | price | last_price (正解) |
|---|---|---|
| 04-01 | 1,200 | 1,280 ✓ |
| 04-02 | 1,150 | 1,280 ✓ |
| 04-03 | 1,280 | 1,280 ✓ |
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING を書くことをチームのコーディングルールにしましょう。RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING も全パーティションを覆うため、このクエリでは同じ結果になります。ここで ROWS BETWEEN を使うのは、物理的な全行を対象にする意図を明示し、境界付きフレームへ応用するときにピア値基準の意味を持ち込まないためです。CTE + ROW_NUMBER() — 「部署ごとの売上トップN名」を抽出する実務パターン
ウィンドウ関数の結果は WHERE句では直接フィルタリングできません(WHEREはウィンドウ関数より前に評価されるため)。そこで実務では CTE(Common Table Expression)または サブクエリ でウィンドウ関数を先に計算し、その結果に対して WHERE で絞り込みます。
WITH cte_name AS ( -- ウィンドウ関数をここで計算する SELECT *, ROW_NUMBER() OVER(...) AS rn FROM table ) SELECT * FROM cte_name WHERE rn <= 2; -- CTEで付けた列名で絞り込む
以下の emp_sales テーブルから、部署(dept)ごとに売上(sales)が高い順の上位2名を抽出してください。CTEを使ったクエリで実装すること。
| dept | emp_name | sales |
|---|---|---|
| 営業 | 田中 | 850,000 |
| 営業 | 佐藤 | 720,000 |
| 営業 | 鈴木 | 930,000 |
| 開発 | 高橋 | 410,000 |
| 開発 | 伊藤 | 380,000 |
| 開発 | 渡辺 | 450,000 |
| dept | emp_name | sales |
|---|---|---|
| 営業 | 鈴木 | 930,000 |
| 営業 | 田中 | 850,000 |
| 開発 | 渡辺 | 450,000 |
| 開発 | 高橋 | 410,000 |
WITH ranked AS ( -- CTE内でウィンドウ関数を計算する(ここでは直接WHERE絞り込みはできない) SELECT dept, emp_name, sales, ROW_NUMBER() OVER( PARTITION BY dept -- deptごとに番号をリセット ORDER BY sales DESC, emp_name -- 売上降順・同率は氏名順で 1, 2, 3... と振る ) AS rn FROM emp_sales ) SELECT dept, emp_name, sales FROM ranked WHERE rn <= 2 -- 部署ごとの上位2名に絞り込む ORDER BY CASE dept WHEN '営業' THEN 1 WHEN '開発' THEN 2 END, sales DESC; /* 実行順序: 1. CTE(ranked) 内部クエリ → PARTITION+ORDER+ROW_NUMBER で順位付与 2. CTE(ranked) の結果を保持 → 仮想テーブルとして参照 3. 外側 SELECT → WHERE rn でフィルタし整列 */
LEGEND
① FROM (CTE内)
FROM emp_sales (CTE ranked の内部)CTE の内部クエリが先に実行されます。emp_sales テーブル全体(6行)を読み込みます。| dept | emp_name | sales |
|---|---|---|
| 営業 | 田中 | 850,000 |
| 営業 | 佐藤 | 720,000 |
| 営業 | 鈴木 | 930,000 |
| 開発 | 高橋 | 410,000 |
| 開発 | 伊藤 | 380,000 |
| 開発 | 渡辺 | 450,000 |
ROW_NUMBER は同売上でも強制的に異なる番号を振ります。この解答は emp_name をタイブレーカーにして選択を決定的にしていますが、境界で同率の2人を両方含めるわけではありません。「同率を含めてトップ2」なら RANK() OVER(...) AS rn にして WHERE rn <= 2 とすれば、同率2位が複数いても全員抽出できます。QUALIFY rn <= 2 という専用句が使え、CTEなしで直接フィルタリングできます。ただし PostgreSQL / MySQL では現在未対応のため、CTE パターンを覚えておくことが最も汎用的です。| dept | emp_name | sales | rn(ROW_NUMBER) | WHERE rn<=2 の判定 |
|---|---|---|---|---|
| 営業 | 鈴木 | 930,000 | 1 | ✓ 抽出 |
| 営業 | 田中 | 850,000 | 2 | ✓ 抽出 |
| 営業 | 佐藤 | 720,000 | 3 | ✗ 除外 |
| 開発 | 渡辺 | 450,000 | 1 | ✓ 抽出 |
| 開発 | 高橋 | 410,000 | 2 | ✓ 抽出 |
| 開発 | 伊藤 | 380,000 | 3 | ✗ 除外 |
SELECT dept, emp_name, ROW_NUMBER() OVER(...) AS rn FROM emp_sales WHERE rn <= 2 と書くと、ほとんどのDBで 「column rn does not exist」 や 「Window functions are not allowed in WHERE」 というエラーになります。ウィンドウ関数の結果で絞り込みたい場合は、必ず CTE かサブクエリで1段ネストしてください。