SQL ウィンドウ関数 — NTILE・FIRST/LAST VALUEの応用

応用ウィンドウ関数前後行参照 (LAG/LEAD)NTILE / セグメント分析移動平均 / フレーム制御FIRST / LAST VALUEPostgreSQL/MySQL8.0+/BigQuery対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

LAG() / LEAD() — 前後の行の値を参照して「前月比・増減」を計算する

LAGLEAD前月比時系列分析KPI推移
前提知識

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;
オフセット(第2引数): LAG(revenue, 2) とすると「2行前」が参照できます。省略時はデフォルト 1(直前行)です。第3引数にデフォルト値を指定でき、前の行が存在しない場合に NULL の代わりにその値が返ります(例: LAG(revenue, 1, 0))。
LAG/LEAD の登場背景: かつては「前月の売上と今月の売上を比べる」ためだけに、同じテーブルに対して 自己JOIN(Self JOIN) が必要でした。LAG/LEAD はそのボイラープレートをゼロにする革命的な関数です。
問題

以下の monthly_revenue テーブルを使い、各月の売上とともに「前月売上(prev_revenue)」「翌月売上(next_revenue)」「前月比増減額(diff_from_prev)」を出力してください。

前後の月が存在しない場合は NULL になることを確認してください。

使用テーブル
▶ monthly_revenue
monthrevenue
2024-01400000
2024-02460000
2024-03430000
2024-04510000
2024-05480000
期待出力
monthrevenueprev_revenuenext_revenuediff_from_prev
2024-01400,000NULL460,000NULL
2024-02460,000400,000430,000+60,000
2024-03430,000460,000510,000-30,000
2024-04510,000430,000480,000+80,000
2024-05480,000510,000NULL-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        → 昇順に並べ替え
  */
解説(テーブル変化・ポイント)
SELECT month, revenue, LAG(revenue) OVER(ORDER BY month) AS prev_revenue, LEAD(revenue) OVER(ORDER BY month) AS next_revenue, revenue - LAG(revenue) OVER(ORDER BY month) AS diff_from_prev FROM monthly_revenue ORDER BY month;
LEGEND
データ取得・読込対象
① FROM
FROM monthly_revenuemonthly_revenue テーブル全体(5行)を読み込みます。
1 / 3
monthrevenue
2024-01400,000
2024-02460,000
2024-03430,000
2024-04510,000
2024-05480,000
全 5行 読込
学習ポイント
Self JOIN が不要になる: LAG/LEAD 以前は、前月比を出すために t1.revenue - t2.revenue のような自己JOINが一般的でした。LAG は同じ前後行参照をクエリ上で直接表現でき、物理的な実行方法はオプティマイザが決定します。実務でのKPI推移計算・ダッシュボード用クエリには必須の関数です。
デフォルト値で NULL を回避できる: LAG(revenue, 1, 0) とすると、前の行が存在しない先頭行に 0 が入ります。「最初の月は前月比 0 として扱いたい」などの要件では第3引数を活用しましょう。
PARTITION BY との組み合わせ: LAG(revenue) OVER(PARTITION BY dept ORDER BY month) とすれば、「部署ごとの前月比」が計算できます。部署が変わると参照ウィンドウがリセットされるため、別の部署の前月を参照してしまうミスが防げます。
▶ LAG / LEAD の参照方向 — 2024-03 行を現在行として見たとき
monthrevenueLAG(1行前)LEAD(1行後)
2024-01400,000
⇧ 2024-02460,000 ▲ LAGが参照する行
▶ 2024-03 (現在行)430,000 460,000 510,000
⇩ 2024-04510,000 ▼ LEADが参照する行
2024-05480,000
LAGは上(過去)を参照、LEADは下(未来)を参照する。PARTITION BY がある場合はパーティション境界をまたいで参照することはない。
アンチパターン
ORDER BY を省略する: LAG(revenue) OVER() のように ORDER BY を書かないと、エンジンが任意の順序で行を評価するため「前の行」が不定になります。LAG/LEAD は必ず OVER(ORDER BY 並び順を決める列) とセットで使ってください。
PARTITION BY を忘れて複数グループを混在させる: 複数の部署や商品のデータが混在するテーブルで PARTITION BY を省略すると、「A部署の最終行」の next がそのまま「B部署の最初行」を参照してしまいます。グループをまたいだ前後参照は必ず PARTITION BY で防ぎましょう。
QUESTION 2

NTILE() — 顧客を購入金額で四分位(Quartile)に分類する

NTILE四分位セグメント分析顧客分析
前提知識

NTILE(n) は、ORDER BY で並べたデータを n 個の均等なバケツ(桶)に分割し、各行に 1〜n のバケツ番号を付与する関数です。

NTILE(4) OVER(ORDER BY amount DESC) AS quartile
-- 全行を amount 降順で並べ、4分割した場合の桶番号(1〜4)を付与する
バケツの行数について: 全行数 ÷ n で割り切れない場合、余りの行は前のバケツ(小さい番号)から1行ずつ順番に追加されます。例えば 10行 ÷ 4バケツなら「3,3,2,2行」の分布になります。
実務での用途: ECサイトの顧客を購入額でセグメント化(VIP/ロイヤル/一般/低頻度)、社員の評価を相対的に5段階評価する(いわゆる「強制分布」)など、全体を相対的なランクに分けたいあらゆる場面で使われます。
問題

以下の customers テーブル(8名の顧客と購入合計額)を使い、total_amount が高い順に 4等分(NTILE(4))して quartile(四分位)番号を付与してください。

quartile=1 が最高購入額グループ(上位25%)、quartile=4 が最低購入額グループです。

使用テーブル
▶ customers
customer_idnametotal_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_idnametotal_amountquartile
C3鈴木120,0001
C6渡辺98,0001
C1田中85,0002
C5伊藤75,0002
C8小林55,0003
C2佐藤42,0003
C4高橋30,0004
C7中村15,0004
模範解答コード
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  → 最終整列
  */
解説(テーブル変化・ポイント)
SELECT customer_id, name, total_amount, NTILE(4) OVER(ORDER BY total_amount DESC) AS quartile FROM customers ORDER BY total_amount DESC;
LEGEND
データ取得・読込対象
① FROM
FROM customerscustomers テーブル全体(8行)を読み込みます。この時点ではランダムな順序です。
1 / 3
customer_idnametotal_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
全 8行 読込(順序不定)
学習ポイント
割り切れない場合の分配ルール: 行数がバケツ数で割り切れないとき、余りは小さい番号のバケツ(上位グループ)から順に1行ずつ追加されます。例えば9行を4分割すると「3,2,2,2」になります。余り1なので最初のバケツだけが3行です。「最高ランクのグループが少し大きくなる」と覚えましょう。
PARTITION BY との組み合わせ: NTILE(4) OVER(PARTITION BY region ORDER BY amount DESC) とすれば、「地域ごとに四分位」を計算できます。全社横断ではなく「東日本の中でのVIP」などの相対評価に使います。
デシル分析(Decile): ECマーケティングでは NTILE(10) を使った「デシル分析」が頻出です。顧客を購入金額で10等分し、上位10%(デシル1)への施策とそれ以外への施策を分けます。
▶ 割り切れない場合の NTILE の分配 — 9行を4分割したケース
total_amountquartile (NTILE=4)備考
1位120,0001余り1行 → バケツ1に追加
2位98,0001
3位90,0001 ← 3行⇦ 余りがここに入る
4位85,0002
5位75,0002 ← 2行
6位55,0003
7位42,0003 ← 2行
8位30,0004
9位15,0004 ← 2行
9 ÷ 4 = 2余り1 → バケツ1だけが3行、残りは全て2行。余りは必ず上位バケツから順に分配される。
アンチパターン
NTILE の quartile 番号と統計の四分位数を混同する: 統計学の Q1〜Q3(第1〜第3四分位数)は値そのもの(例: Q1=75,000円)ですが、NTILE(4) が返すのは「そのグループに属するかを示す番号(1〜4)」です。NTILE は「どのバケツか」を教えるだけで、バケツの境界値(パーセンタイル値)は教えてくれません。境界値が必要なら PERCENTILE_CONT 関数を使います。
同点の順位が不定になる: ORDER BY に指定した列に同じ値(タイ)がある場合、NTILE の割り当てはエンジン依存で不確定になります。例えば 75,000 円が2人いるとき、どちらがバケツ2でどちらがバケツ3に入るかは保証されません。一意にしたい場合は ORDER BY total_amount DESC, customer_id のようにタイブレーカーを追加してください。
QUESTION 3

ROWS BETWEEN — ウィンドウフレームを指定して「直近3日間移動平均」を計算する

ROWS BETWEEN移動平均フレーム指定時系列分析
前提知識

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 vs RANGE の違い: ROWS は物理的な行数でフレームを決めます。RANGE は値の範囲でフレームを決めます(同じ日付が複数行あると、RANGE はそれらをまとめてフレームに含めます)。移動平均など物理行数基準の計算には ROWS を使うのが安全です。
問題

以下の daily_temperature テーブル(1暦日につき1行で日付に欠落なし)から、「当日を含む直近3日間の気温移動平均(moving_avg_3d)」を求めてください。小数点第1位まで出力すること。このデータでは直近3行が直近3日間に一致します。

最初の2行は3日分揃っていないため、揃っている分だけで平均を計算します(出力例参照)。

使用テーブル
▶ daily_temperature
measured_datetemp
2024-07-0128.5
2024-07-0231.2
2024-07-0333.0
2024-07-0429.8
2024-07-0532.1
2024-07-0635.4
期待出力
measured_datetempmoving_avg_3d
2024-07-0128.528.5
2024-07-0231.229.9
2024-07-0333.030.9
2024-07-0429.831.3
2024-07-0532.131.6
2024-07-0635.432.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 出力               → 列を出力
  */
解説(テーブル変化・ポイント)
SELECT measured_date, temp, ROUND( AVG(temp) OVER( ORDER BY measured_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) , 1) AS moving_avg_3d FROM daily_temperature ORDER BY measured_date;
LEGEND
データ取得・読込対象
① FROM
FROM daily_temperaturedaily_temperature テーブル全体(6行)を読み込みます。
1 / 3
measured_datetemp
07-0128.5
07-0231.2
07-0333.0
07-0429.8
07-0532.1
07-0635.4
全 6行 読込
学習ポイント
移動平均はノイズ除去の基本テクニック: 日次の気温や売上は日ごとの振れ幅が大きく、トレンドが見えにくいです。直近3日・7日の移動平均を加えることで、短期的なノイズを平滑化しトレンドが見えやすくなります。株価チャート・KPIダッシュボードで最もよく使われる計算パターンです。
ROWS vs RANGE の実務的な差: 境界付きの RANGE は「行数」ではなく ORDER BY 値の差を基準とし、日付オフセットの正確な構文はDB方言ごとに異なります。同じ日付の行が複数ある場合はピア行をまとめて含めます。「直近N行」が要件なら ROWS を使い、日付の欠落や同日複数行があり得るデータで「暦上の期間」が要件ならDB方言に合う日付 interval を使いましょう。
PARTITION BY との組み合わせ: PARTITION BY product_id ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW で「商品ごとの直近7行移動平均」が計算できます。1暦日につき1行で日付に欠落がなければ7日間移動平均と一致します。パーティション境界をまたいで計算されることはありません。
▶ ROWS BETWEEN フレームが行ごとにスライドする様子
measured_date temp 集計フレーム内の行(青=現在行、薄=過去行) avg
07-0128.5 28.5 28.5
07-0231.2 28.5 + 31.2 29.9
07-0333.0 28.5 + 31.2 + 33.0 30.9
07-0429.8 31.2 + 33.0 + 29.8 ← 07-01が脱落 31.3
07-04 から「07-01が集計フレームの外に出る」ことに注目。フレームは常に「直近3行」を維持しながら下にスライドしていく。
アンチパターン
OVER(ORDER BY) のデフォルトフレームを誤解する: AVG(temp) OVER(ORDER BY date) と書いた場合、一般的なデフォルトフレームは同じ ORDER BY 値のピア行も含む RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW です。これは固定幅の「移動平均」ではなく「累計平均(先頭から現在の値まで)」です。移動平均を計算したいなら、必ず ROWS BETWEEN N PRECEDING AND CURRENT ROW を明示してください。
ORDER BY を省略して ROWS BETWEEN だけ指定する: ROWS BETWEEN はフレームを「行の相対位置」で指定するため、ORDER BY がなければ「何を基準に前後2行か」が不定になります。ROWS BETWEEN は必ず ORDER BY とセットで使ってください。
QUESTION 4

FIRST_VALUE() / LAST_VALUE() — 期間の「初値」と「終値」を全行に付与する

FIRST_VALUELAST_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 の致命的な落とし穴: LAST_VALUEROWS 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_prices
stocktrade_dateprice
TYK2024-04-011,200
TYK2024-04-021,150
TYK2024-04-031,280
OSK2024-04-01850
OSK2024-04-02920
OSK2024-04-03890
期待出力
stocktrade_datepricefirst_pricelast_price
OSK2024-04-01850850890
OSK2024-04-02920850890
OSK2024-04-03890850890
TYK2024-04-011,2001,2001,280
TYK2024-04-021,1501,2001,280
TYK2024-04-031,2801,2001,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 で整列
*/
解説(テーブル変化・ポイント)
SELECT stock, trade_date, price, FIRST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) 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;
LEGEND
データ取得・読込対象
① FROM
FROM stock_pricesstock_prices テーブル全体(6行)を読み込みます。
1 / 4
stocktrade_dateprice
TYK04-011,200
TYK04-021,150
TYK04-031,280
OSK04-01850
OSK04-02920
OSK04-03890
全 6行 読込
学習ポイント
FIRST_VALUE は比較的安全、LAST_VALUE は常に要注意: FIRST_VALUE は一般的なデフォルトフレームでも「先頭行の値」という意味が成立します(先頭行は常にフレーム内に含まれるため)。しかし LAST_VALUE は現在のフレームの末尾を返し、この例のように ORDER BY 値が一意なら現在行になるため、全パーティションのフレームを明示しない限り期待通りに動きません。この非対称性が混乱を生みます。
MAX/MIN で代替できる場合もある: MAX(price) OVER(PARTITION BY stock) は「銘柄内の最高値を全行に付与」します。「最初・最後の値」ではなく「最大・最小の値」が欲しい場合は MAX/MIN の方がシンプルで LAST_VALUE の罠もありません。
▶ LAST_VALUE のバグ — フレーム指定なし vs あり(TYK のパーティションのみ抜粋)
✗ フレーム指定なし(バグ)
LAST_VALUE(price) OVER(
  PARTITION BY stock
  ORDER BY trade_date
  -- フレーム省略!
  -- 一般的なデフォルト: 先頭〜現在のピア行
)
trade_datepricelast_price (バグ)
04-011,2001,200 ← 自分自身!
04-021,1501,150 ← 自分自身!
04-031,2801,280 ← 自分自身!
! 常に price と同じ値。LAST_VALUE が機能していない。
✓ フレーム指定あり(正解)
LAST_VALUE(price) OVER(
  PARTITION BY stock
  ORDER BY trade_date
  ROWS BETWEEN
    UNBOUNDED PRECEDING
    AND UNBOUNDED FOLLOWING
)
trade_datepricelast_price (正解)
04-011,2001,280 ✓
04-021,1501,280 ✓
04-031,2801,280 ✓
✓ 全行に期間終値 1,280 が正しく付与される。
アンチパターン
LAST_VALUE でフレーム指定を省略する(最重要): 上図の通り、この例のように trade_date が一意なら、フレーム指定なしの LAST_VALUE は現在行の値を返します。エラーにはならないため、クエリが通っているのに結果が silently wrong(静かに間違っている)という最も危険なバグです。パーティションの終値を求める LAST_VALUE には必ず ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING を書くことをチームのコーディングルールにしましょう。
この例でも RANGE は必ず異なると思い込む: RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING も全パーティションを覆うため、このクエリでは同じ結果になります。ここで ROWS BETWEEN を使うのは、物理的な全行を対象にする意図を明示し、境界付きフレームへ応用するときにピア値基準の意味を持ち込まないためです。
QUESTION 5

CTE + ROW_NUMBER() — 「部署ごとの売上トップN名」を抽出する実務パターン

CTEWITHROW_NUMBERTopN抽出実務最重要
前提知識

ウィンドウ関数の結果は 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で付けた列名で絞り込む
「グループごとのTop-N抽出」はSQLの最頻出実務パターン: 「部署ごとの売上トップ3」「商品カテゴリごとの上位5件」「ユーザーごとの直近アクセス1件」など、あらゆるグループに対してTop-Nを求める問題は CTE + ROW_NUMBER で一発解決できます。この組み合わせを体に染み込ませてください。
問題

以下の emp_sales テーブルから、部署(dept)ごとに売上(sales)が高い順の上位2名を抽出してください。CTEを使ったクエリで実装すること。

使用テーブル
▶ emp_sales
deptemp_namesales
営業田中850,000
営業佐藤720,000
営業鈴木930,000
開発高橋410,000
開発伊藤380,000
開発渡辺450,000
期待出力
deptemp_namesales
営業鈴木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 でフィルタし整列
  */
解説(テーブル変化・ポイント)
WITH ranked AS ( SELECT dept, emp_name, sales, ROW_NUMBER() OVER( PARTITION BY dept ORDER BY sales DESC, emp_name ) AS rn FROM emp_sales ) SELECT dept, emp_name, sales FROM ranked WHERE rn <= 2 ORDER BY CASE dept WHEN '営業' THEN 1 WHEN '開発' THEN 2 END, sales DESC;
LEGEND
データ取得・読込対象
① FROM (CTE内)
FROM emp_sales (CTE ranked の内部)CTE の内部クエリが先に実行されます。emp_sales テーブル全体(6行)を読み込みます。
1 / 4
deptemp_namesales
営業田中850,000
営業佐藤720,000
営業鈴木930,000
開発高橋410,000
開発伊藤380,000
開発渡辺450,000
全 6行 読込
学習ポイント
なぜ WHERE に直接ウィンドウ関数が使えないか: SQLの論理的な評価順序は FROM → WHERE → GROUP BY → HAVING → Window関数 → SELECT → ORDER BY です。WHEREはウィンドウ関数より先に評価されるため、ウィンドウ関数の計算結果(rn など)はWHERE時点ではまだ存在しません。CTEやサブクエリで1段ネストすることで、ウィンドウ関数の結果を「既存の列」として扱えるようになります。
同率を含める場合は RANK/DENSE_RANK に変更: ROW_NUMBER は同売上でも強制的に異なる番号を振ります。この解答は emp_name をタイブレーカーにして選択を決定的にしていますが、境界で同率の2人を両方含めるわけではありません。「同率を含めてトップ2」なら RANK() OVER(...) AS rn にして WHERE rn <= 2 とすれば、同率2位が複数いても全員抽出できます。
BigQuery / Snowflake の QUALIFY 句: 一部の最新データウェアハウスでは QUALIFY rn <= 2 という専用句が使え、CTEなしで直接フィルタリングできます。ただし PostgreSQL / MySQL では現在未対応のため、CTE パターンを覚えておくことが最も汎用的です。
▶ CTE の中間テーブル (ranked) — WHERE で絞り込む前の全行
deptemp_namesales rn(ROW_NUMBER)WHERE rn<=2 の判定
営業 鈴木930,000 1 ✓ 抽出
営業 田中850,000 2 ✓ 抽出
営業 佐藤720,000 3 ✗ 除外
開発 渡辺450,000 1 ✓ 抽出
開発 高橋410,000 2 ✓ 抽出
開発 伊藤380,000 3 ✗ 除外
CTEがなければ rn 列は存在しないため WHERE で絞り込めない。CTE(または サブクエリ)で1段ネストすることで rn を「普通の列」として扱えるようになる。
アンチパターン
WHERE 句でウィンドウ関数を直接使う(エラーになる): 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段ネストしてください。
HAVING 句でも同様のエラーが出る: HAVING はウィンドウ関数よりも前に評価されるため、同様に直接使えません。CTE/サブクエリの外側のWHERE句を使うか、対応DBでは QUALIFY を使います。
実務コラム:グループ内Top-N抽出
このCTE + ROW_NUMBER のパターンは、実務で最も頻繁に使われるウィンドウ関数の応用例です。応用例: 「各ユーザーの最新ログイン1件だけを取得」(ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_time DESC) AS rn → WHERE rn = 1)、「各商品カテゴリで最も売れた5商品のレポート」「各店舗で売上が最下位の担当者一覧」(ORDER BY sales ASC)など、グループ内の上位・下位N件の抽出はあらゆるデータ分析に登場します。このパターンを反射的に書けるようになることがSQL中級者の証です。