株式会社ジオデータ開発 ロゴ
株式会社ジオデータ開発GEO DATA DEVELOPMENT Inc.

DATABASE / SQL / ANALYTICS

SQLウィンドウ関数入門ROW_NUMBER・PARTITION BYで「顧客ごとの1件」を取り出す

「顧客ごとの初回注文と、そのときの金額を一覧にしたい」。一見単純な依頼ですが、最小日付を求めるだけでは注文金額まで正しく取得できません。SQLの集約とウィンドウ関数を、実際の業務データを使って整理します。

SQLウィンドウ関数ROW_NUMBERPARTITION BYOVERORDER BYGROUP BY

01顧客ごとの初回注文を知りたい

ネットショップで新規顧客の獲得施策を評価するとき、「誰が、いつ、最初に、いくら買ったか」を知りたくなります。顧客ごとの注文件数や最初の日付だけでなく、その注文に紐づいた金額が必要です。

同じ課題は、利用者ごとの最初のログイン、製品ごとの最新価格、設備ごとの直近点検、担当者ごとの最新問い合わせにも現れます。「グループごとに1行選ぶ」ことは、業務アプリで繰り返し登場するSQLパターンです。

02サンプルデータと取り出したい結果

架空の注文履歴テーブルを考えます。注文回は顧客内で1から増える番号、注文日は注文を確定した日、金額はその注文の金額です。以下は説明用データです。

顧客ID注文回注文日金額(円)
10112026-04-033,000
10122026-05-117,000
10212026-04-075,000
10222026-04-162,000
10312026-06-018,000
10322026-06-024,000

欲しいのは顧客101の1回目3,000円、102の1回目5,000円、103の1回目8,000円という3行です。顧客101の2回目は金額が大きくても選びません。

CREATE TABLE 注文履歴 (
  顧客ID INTEGER NOT NULL,
  注文回 INTEGER NOT NULL,
  注文日 DATE NOT NULL,
  金額 INTEGER NOT NULL,
  PRIMARY KEY (顧客ID, 注文回)
);

この例では複合主キーにより、同じ顧客に同じ注文回が重複しません。後半では、こうした一意性の前提が崩れた場合も扱います。

03GROUP BYは行をまとめる

GROUP BYは同じ顧客IDの行を集約し、MINやMAXなどの集約値を求めます。

SELECT 顧客ID, MIN(注文回) AS 初回
FROM 注文履歴
GROUP BY 顧客ID;
顧客ID初回
1011
1021
1031

この時点で「そのときの金額」は結果にありません。MIN(注文回)とMIN(金額)を同じSELECTに書いても、両者が同じ注文行から取られる保証はありません。たとえば顧客101の最小金額は3,000円ですが、別のデータなら最小金額が2回目であることもあります。

04MINとJOINで元の行に戻る

元の行の金額を取得するには、先ほどの「顧客IDと初回」の集約結果を、注文履歴に結合する方法があります。サブクエリの別名初回一覧は中間表の名前です。

SELECT o.顧客ID, o.注文回, o.注文日, o.金額
FROM 注文履歴 AS o
INNER JOIN (
  SELECT 顧客ID, MIN(注文回) AS 初回
  FROM 注文履歴
  GROUP BY 顧客ID
) AS 初回一覧
  ON o.顧客ID = 初回一覧.顧客ID
 AND o.注文回 = 初回一覧.初回;

①集約して初回番号を求める → ②顧客IDと注文回の両方で元表に結合する → ③該当する行の金額も取得する、という流れです。顧客IDだけでJOINすると、該当顧客のすべての注文が戻ってくるため、結合条件の2つ目が重要です。

「初回番号」が重複しないという主キーの前提があれば顧客ごとに1件になります。もし最小の日時が複数行で同じなら、その日時で結合した結果は複数件になる場合があります。

05ウィンドウ関数は行を残したまま計算

ウィンドウ関数(window function / 分析関数)は、元の行を消さずに、その行が属するグループや並び順に基づく値を計算します。ROW_NUMBER()なら、各行に連番を付けられます。

GROUP BY

6行 → 顧客別に3行へ集約。金額など元の列はそのまま残らない。

ウィンドウ関数

6行 → 6行のまま番号列を追加。あとから番号1を選べる。

OVER (...)は関数が計算対象とする範囲・順序を指定する構文です。「ウィンドウ」は画面上の窓ではなく、計算の対象となる行集合を指します。

06PARTITION BYはグループ分け

ROW_NUMBER() OVER (
  PARTITION BY 顧客ID
  ORDER BY 注文回
)

PARTITION BY 顧客IDは、顧客101・102・103ごとに別々の番号付けグループを作ります。グループが変わると番号は1からやり直しです。GROUP BYと似た「分ける」働きでも、各行を1行に縮めません。

顧客ID注文回PARTITION付与番号
1011顧客1011
1012顧客1012
1021顧客1021
1022顧客1022
1031顧客1031
1032顧客1032

注意したいのは、ここでのパーティションはSQLの分析上の論理的なグループです。DBのディスク領域を分割するテーブルパーティショニングとは異なります。

07ORDER BYはグループ内の順番

ORDER BY 注文回 ASCなら注文回が小さいものから、DESCなら大きいものから番号を付けます。ASCは昇順で、省略時も通常は昇順です。DESCは降順です。

指定番号1になる行活用例
ORDER BY 注文回 ASC各顧客の最初の注文初回購入を調べる
ORDER BY 注文回 DESC各顧客の最後の注文直近の注文を調べる
ORDER BY 金額 DESC各顧客の最高額の注文最大購入額を調べる

ウィンドウ内のORDER BYと、最終的な表示順のORDER BYは別です。結果一覧の並び順も保証したい場合は外側のSELECTにもORDER BYを記述します。

08ROW_NUMBERで1件を選ぶ

各顧客について番号を振った後、番号1だけを取り出します。計算された別名「番号」は通常、同じSELECTのWHEREにはそのまま指定できないため、サブクエリに包みます。

SELECT 顧客ID, 注文回, 注文日, 金額
FROM (
  SELECT 顧客ID, 注文回, 注文日, 金額,
         ROW_NUMBER() OVER (
           PARTITION BY 顧客ID
           ORDER BY 注文回 ASC
         ) AS 番号
  FROM 注文履歴
) AS x
WHERE x.番号 = 1
ORDER BY 顧客ID;
顧客ID注文回注文日金額
10112026-04-033,000
10212026-04-075,000
10312026-06-018,000

09CTEで読みやすいSQLにする

同じ処理をWITH句(共通表式、CTE)で書くと、「番号を付ける処理」と「選ぶ処理」の境界が読みやすくなります。

WITH 番号付き注文 AS (
  SELECT 顧客ID, 注文回, 注文日, 金額,
         ROW_NUMBER() OVER (
           PARTITION BY 顧客ID
           ORDER BY 注文回
         ) AS 番号
  FROM 注文履歴
)
SELECT 顧客ID, 注文回, 注文日, 金額
FROM 番号付き注文
WHERE 番号 = 1;

CTEを使えば必ず速くなるわけではありません。DB製品やバージョン、最適化・実行計画により異なります。まず意味が明快なSQLを書き、必要に応じて性能を測るのが基本です。

10同じ日時があるときの落とし穴

注文回は顧客ごとに一意ですが、注文日時を基準に「最初の1件」を取ると、同時刻の注文が複数あるかもしれません。ROW_NUMBER()はそれでも異なる番号を振りますが、同順位同士のどちらが1になるかは追加の並び順がなければ安定しません。

-- 注文日時だけでは同時刻の順序が不定
ROW_NUMBER() OVER (
  PARTITION BY 顧客ID ORDER BY 注文日時
)

-- 一意な注文IDで同順位を解消
ROW_NUMBER() OVER (
  PARTITION BY 顧客ID ORDER BY 注文日時, 注文ID
)

前半のMINとJOIN方式では同じ最小日時を持つ行が2件とも返る一方、ROW_NUMBER方式はその中から1件だけ返します。したがって、「どちらも常に同じ結果」は成立しません。比較するには一意性や同順位時の業務ルールを明確にする必要があります。

11ROW_NUMBER・RANK・DENSE_RANK

同順位の扱いが違う3つの関数も知っておくと、応用範囲が広がります。得点が90、90、75、60の4行なら次のようになります。

得点ROW_NUMBERRANKDENSE_RANK
901 または 211
902 または 111
75332
60443

ROW_NUMBERは連番、RANKは同順位の次に飛び番、DENSE_RANKは同順位の次も連続した順位です。上の例は得点降順を想定しています。90点の2行についてROW_NUMBERの順序を固定するには追加キーが必要です。

SELECT 顧客ID, 金額,
       ROW_NUMBER() OVER (PARTITION BY 顧客ID ORDER BY 金額 DESC) AS 連番,
       RANK() OVER (PARTITION BY 顧客ID ORDER BY 金額 DESC) AS 順位,
       DENSE_RANK() OVER (PARTITION BY 顧客ID ORDER BY 金額 DESC) AS 密順位
FROM 注文履歴;

12初回・最新・売上上位を使い分ける

欲しい情報PARTITION BYORDER BY選び方
顧客ごとの初回注文顧客ID注文日時 ASC, 注文ID ASCROW_NUMBER = 1
顧客ごとの最新注文顧客ID注文日時 DESC, 注文ID DESCROW_NUMBER = 1
商品ごとの最新価格商品ID適用日時 DESC, 価格履歴ID DESCROW_NUMBER = 1
店舗ごとの売上上位3件店舗ID売上額 DESC, 注文ID ASCROW_NUMBER <= 3
店舗ごとの同率1位すべて店舗ID売上額 DESCRANK = 1

最後の「同率1位すべて」では、RANKのORDER BYに注文IDを足すと同率として扱われなくなるので注意してください。同順位を残したい場合は、順位の定義に含める列を意識します。

13NULL・フィルタ・実行順序の注意

NULLが含まれる列をORDER BYに使うと、NULLを先にするか後にするかがDB製品や指定により異なる場合があります。実運用ではNULLを除外するか、DBの対応構文で明示的に扱います。

-- 例: 注文日時が存在する注文だけから選ぶ
WITH ranked AS (
  SELECT *, ROW_NUMBER() OVER (
    PARTITION BY 顧客ID
    ORDER BY 注文日時, 注文ID
  ) AS rn
  FROM 注文履歴
  WHERE 注文日時 IS NOT NULL
)
SELECT * FROM ranked WHERE rn = 1;

内側のWHEREは「番号付けの対象」を絞り、外側のWHEREは「番号を付け終わった行」を絞ります。たとえば返品を除くかどうかを番号付け前後のどちらで判断するかで、答えが変わり得ます。

14インデックスと実行計画

大量の注文履歴を顧客別に並べ替える処理では、ソートやスキャンのコストが生じます。(顧客ID, 注文日時, 注文ID)など、絞り込み・グループ分け・順序に合った複合インデックスは候補になりますが、必ずそのインデックスが使われるとは限りません。

-- 実際の列名とDB仕様に合わせて検討
CREATE INDEX idx_orders_customer_time
  ON 注文履歴 (顧客ID, 注文日時, 注文ID);

-- PostgreSQL等で実行計画を確認
EXPLAIN ANALYZE
SELECT ...;

対象行数、フィルタ条件、索引の選択性、DBの最適化機能によってGROUP BY + JOINとウィンドウ関数の優劣は変わります。SQLの短さだけで性能を決めつけないようにします。

15業務アプリで使うときの安全設計

複数企業が同じDBを利用するマルチテナントのSaaSでは、顧客IDだけでPARTITIONを切ると別テナントのデータが混在し得ます。アクセス制御を適切に実施したうえで、テナントIDと顧客IDの組でグループ化するなど、業務上の識別子の範囲を合わせます。

-- 例: テナント内の顧客ごとの最新注文
ROW_NUMBER() OVER (
  PARTITION BY テナントID, 顧客ID
  ORDER BY 注文日時 DESC, 注文ID DESC
)

この記述だけでアクセス制御が完成するわけではありません。認証・認可、テナント単位のWHERE条件、DB側の行レベルセキュリティなどをシステム要件に応じて組み合わせます。またユーザー入力をSQLに埋め込む場合はプレースホルダを使用し、SQLインジェクションを防止します。

16データを動かして結果を比べる

実際のDBには接続しないブラウザ内シミュレーターです。元データ・順位列・抽出結果を並べ、顧客別の初回/最新/最高額と、ROW_NUMBER・RANK・DENSE_RANKを切り替えて確認できます。同値を含むデータも試してください。

INTERACTIVE SQL LAB

顧客ごとの1件を取り出してみよう

数字を編集すると、グループ内順位と抽出結果が即座に変わります。DBに接続しない学習用モデルです。

何を取り出す?

ORDER BY 注文回 ASC

番号・順位の種類

1. 元の注文履歴を編集

同じ顧客・同じ注文回や同じ金額も設定できます。順位比較用に重複を許したモデルなので、本文の複合主キーを持つ実DBとは条件が異なります。

顧客ID注文回金額(円)操作

2. PARTITION BYでグループを作り、番号を付ける

背景色で顧客ごとの区切りを表示しています。順序が同じ行には説明用に入力行IDで連番を決めていますが、下記SQLにはそのキーを含めていないため、実際のDBで同じ順序になる保証はありません。

顧客ID注文回金額ROW_NUMBERRANKDENSE_RANK
10113,000111
10127,000222
10215,000111
10222,000222
10318,000111
10324,000222

3. 抽出結果

3行
顧客ID注文回金額順位
10113,0001
10215,0001
10318,0001

RANK / DENSE_RANKで順位1を選ぶと同順位の複数行が返る場合があります。ROW_NUMBERは各顧客グループにつき必ず1行だけ選びます(空でないグループの場合)。

4. 対応するSQL

WITH 順位付き AS (
  SELECT 顧客ID, 注文回, 金額,
         ROW_NUMBER() OVER (
           PARTITION BY 顧客ID
           ORDER BY 注文回 ASC
         ) AS 順位
  FROM 注文履歴
)
SELECT 顧客ID, 注文回, 金額, 順位
FROM 順位付き
WHERE 順位 = 1
ORDER BY 顧客ID, 順位;

本ラボは選択したSQLの概念的な結果をJavaScriptで再現します。実DBでの実行、SQL方言の検証、実行計画の再現は行いません。

理解チェック

「各顧客の最高金額の注文を、同額ならすべて残す」にはどの関数が適切でしょうか。

17設計時の確認事項

何ごとに1件か(グループキー)を決めた
最初・最後・最大など並び順を決めた
同値の場合のタイブレークを決めた
1件だけか、同順位全件かを決めた
NULL・削除済み・返品等の対象条件を決めた
行数が増えた際の実行計画を確認した
テナント境界と認可条件を確認した
元の行の列を正しく取り出している

18まとめ

GROUP BYはグループを集約し、ウィンドウ関数は元の行を保持して計算します。顧客ごとの初回注文をその金額とともに取得するとき、MINとJOINを使う方法に加え、ROW_NUMBERで順位を付けて1位だけ抽出する方法があります。

PARTITION BYは「誰ごとに」、ORDER BYは「何の順番で」、ROW_NUMBERは「何番を振るか」。この順で考えると、設備の直近点検や製品の最新価格など、多くの業務要件へ応用できます。最後に必ず確認したいのは、同値の扱いとデータの一意性です。

参考資料