「顧客ごとに最後の注文だけを出したい」「端末ごとに最新のログを1行ずつ見たい」。こうしたグループごとの最新1件は、実務のSQLで特によく出てくる要件です。英語圏では greatest-n-per-group と呼ばれる定番の問題でもあります。
素直に GROUP BY と MAX だけで書こうとすると、日時は取れても同じ行のほかの列がうまく取れません。この記事では、ROW_NUMBER・相関サブクエリ・JOIN の3つの書き方を同じデータで試し、結果の違いと選び方を整理します。同じ日時が並んだときに何件返るか、どのインデックスを用意すれば速くなるかも押さえておきましょう。
このシナリオで考える
題材は、ネットショップの注文テーブルです。1人の顧客が何度も注文するので、customer_id ごとに複数の行があります。ここから「顧客ごとの最新の注文」を1行ずつ取り出すのが今回のゴールです。
あとで落とし穴を確かめるため、顧客3には同じ日時の注文を2件入れてあります。
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT NOT NULL,
ordered_at DATETIME NOT NULL,
amount INT NOT NULL
);
INSERT INTO orders VALUES
(1, 1, '2026-10-01 10:00:00', 3000),
(2, 1, '2026-10-05 15:30:00', 4500),
(3, 2, '2026-09-28 09:00:00', 1200),
(4, 2, '2026-10-03 18:00:00', 8000),
(5, 3, '2026-10-04 12:00:00', 2500),
(6, 3, '2026-10-04 12:00:00', 3200), -- id 5 と同じ日時
(7, 1, '2026-09-20 08:00:00', 1800);
期待する答えは、顧客1が id 2、顧客2が id 4 です。顧客3は id 5 と 6 が同着になります。
GROUP BYとMAXだけでは足りない理由
最初に思いつくのは、次のような書き方でしょう。
SELECT customer_id, MAX(ordered_at) AS last_ordered_at, amount
FROM orders
GROUP BY customer_id;
MySQL 8.0 の既定の設定では、このSQLはエラーになります。ONLY_FULL_GROUP_BY(GROUP BY に書いていない列の参照を禁止するモード)が有効なためです。このモードを外すと動きますが、amount にはグループ内のどれかの行の値が入ります。MAX を取った行の値になる保証はありません。
集約関数(MAX や SUM)は列ごとに独立して計算されます。「最大の日時を持つ行」をまるごと取るには、別の書き方が必要です。それが次の3つです。
方法1:ROW_NUMBERで順位を付けて1位を取る
MySQL 8.0 以降なら、まずこの書き方を覚えてください。ウィンドウ関数の ROW_NUMBER で、グループ内に新しい順の連番を振ります。そのうえで連番が1の行だけを残します。
WITH ranked AS (
SELECT
id, customer_id, ordered_at, amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY ordered_at DESC, id DESC
) AS rn
FROM orders
)
SELECT id, customer_id, ordered_at, amount
FROM ranked
WHERE rn = 1;
+----+-------------+---------------------+--------+
| id | customer_id | ordered_at | amount |
+----+-------------+---------------------+--------+
| 2 | 1 | 2026-10-05 15:30:00 | 4500 |
| 4 | 2 | 2026-10-03 18:00:00 | 8000 |
| 6 | 3 | 2026-10-04 12:00:00 | 3200 |
+----+-------------+---------------------+--------+
ポイントは次の3つです。
- PARTITION BY customer_id:顧客ごとに連番を1から振り直す
- ORDER BY ordered_at DESC:新しい注文ほど小さい番号になる
- id DESC を追加:日時が同じでも順序が1通りに決まる
ウィンドウ関数は WHERE 句に直接書けません。そのため CTE(WITH句)やサブクエリで一度包み、外側で rn = 1 を絞り込みます。rn <= 3 に変えれば「顧客ごとの最新3件」も同じ形で取れる点が便利です。
同着を全部残したいときは、ROW_NUMBER を RANK に替えます。RANK は同じ値に同じ順位を付けるので、顧客3は id 5 と 6 の2行が返ります。
方法2:相関サブクエリで最大の日時と比べる
相関サブクエリ(外側の行の値を使って毎回実行されるサブクエリ)でも書けます。MySQL 公式リファレンスにも載っている古典的な方法です。
SELECT o.id, o.customer_id, o.ordered_at, o.amount
FROM orders AS o
WHERE o.ordered_at = (
SELECT MAX(o2.ordered_at)
FROM orders AS o2
WHERE o2.customer_id = o.customer_id
);
+----+-------------+---------------------+--------+
| id | customer_id | ordered_at | amount |
+----+-------------+---------------------+--------+
| 2 | 1 | 2026-10-05 15:30:00 | 4500 |
| 4 | 2 | 2026-10-03 18:00:00 | 8000 |
| 5 | 3 | 2026-10-04 12:00:00 | 2500 |
| 6 | 3 | 2026-10-04 12:00:00 | 3200 |
+----+-------------+---------------------+--------+
「自分と同じ顧客の中で、最大の日時を持つ行か」を1行ずつ確かめる、と読むと分かりやすいでしょう。ウィンドウ関数が使えない MySQL 5.7 以前でも動きます。
注意したいのは、顧客3が2行返っている点です。最大値との一致で判定するため、同着はすべて残ります。1行に絞るには、比べる対象を日時ではなく id にする工夫が必要です。
-- 同着のときは id の大きい方を1行だけ返す
SELECT o.id, o.customer_id, o.ordered_at, o.amount
FROM orders AS o
WHERE o.id = (
SELECT o2.id
FROM orders AS o2
WHERE o2.customer_id = o.customer_id
ORDER BY o2.ordered_at DESC, o2.id DESC
LIMIT 1
);
方法3:集計結果とJOINする
3つ目は、先に顧客ごとの最大日時を集計し、その結果を元のテーブルと結合(JOIN)する方法です。
SELECT o.id, o.customer_id, o.ordered_at, o.amount
FROM orders AS o
JOIN (
SELECT customer_id, MAX(ordered_at) AS max_at
FROM orders
GROUP BY customer_id
) AS latest
ON o.customer_id = latest.customer_id
AND o.ordered_at = latest.max_at;
結果は方法2と同じ4行です。集計は1回で済み、相関サブクエリのように行ごとの実行にはなりません。グループの数が多い集計バッチなどで扱いやすい書き方になります。
こちらも最大値との一致で結合するため、同着は複数行になります。1行に絞りたいなら、方法1の ROW_NUMBER を使う方が素直です。
3つの方法の比較
| 方法 | 同着の扱い | 上位N件への拡張 | MySQL 5.7 | 向いている場面 |
|---|---|---|---|---|
| ROW_NUMBER | ORDER BY 次第で1行に決まる | rn <= N で簡単 | 使えない | 8.0以降の第一候補 |
| 相関サブクエリ | 全部返る(LIMIT 1 版なら1行) | 書きにくい | 使える | 一部の顧客だけを取るとき |
| 集計+JOIN | 全部返る | 書きにくい | 使える | 全顧客をまとめて集計するとき |
性能はデータ量とインデックスで大きく変わります。迷ったら EXPLAIN で実行計画を見比べてください。
よくある落とし穴と注意点
同着で件数が増える・結果が毎回変わる
最も多いトラブルは、同じ日時の行があるケースです。MAX との一致で判定すると、件数が想定より増えます。ROW_NUMBER でも ORDER BY ordered_at DESC だけでは、同着のどちらが1位になるかが決まりません。実行のたびに結果が変わる可能性があります。
対策は、ORDER BY の最後に主キーを足して順序を一意にすることです。業務上「同着なら金額の大きい方」などのルールがあれば、それを先に書きます。
インデックスは (グループ列, 並べ替え列) の順で
3つの方法はどれも「顧客ごとに日時の新しい順に並べる」処理をしています。そのため、次の複合インデックスがあると速くなります。
CREATE INDEX idx_orders_customer_ordered
ON orders (customer_id, ordered_at);
列の順序が逆の (ordered_at, customer_id) では、効果がほとんど出ません。左端の列でグループを絞り、その中で日時順に並ぶ形が理想です。
NULLの日時は対象外になる
ordered_at に NULL が入る設計だと、MAX は NULL を無視します。ROW_NUMBER では、MySQL の DESC 順で NULL が最後に並びます。日時が未設定の行を「最新」とみなすかどうかは、事前に決めておきましょう。
まとめ
グループごとの最新1件は、GROUP BY と MAX だけでは取れません。MySQL 8.0 以降なら、ROW_NUMBER と PARTITION BY で連番を振り、1番を残す書き方が基本です。古いバージョンでは、相関サブクエリや集計結果との JOIN を使います。
どの方法でも、同着の扱いを ORDER BY やルールで決めておくことが大切です。(グループ列, 日時列) の複合インデックスも忘れずに用意してください。
参考リンク
- MySQL 8.0 リファレンス: The Rows Holding the Group-wise Maximum of a Certain Column
- MySQL 8.0 リファレンス: Window Function Descriptions
- MySQL 8.0 リファレンス: MySQL Handling of GROUP BY
アイキャッチ画像: Photo by Jantine Doornbos on Unsplash
