顧客ごとに新しい注文を2件ずつ取る、のような各グループの上位N件は、MySQL 8.0.14 から使える LATERAL 派生テーブルで素直に書けます。FROM 句のサブクエリ(派生テーブル)の中から、左に書いたテーブルの列を参照できるようになる機能です。顧客1人ずつに ORDER BY と LIMIT をかけるイメージで書けるので、ROW_NUMBER で番号を振ってから絞るより短くなる場面があります。注文のない顧客も残す LEFT JOIN LATERAL の書き方や、集計を何列もまとめて取る使い方、同じ日付が並んだときの落とし穴も、実行結果つきで紹介します。
以下の結果は MySQL 8.0 で実際に実行したものです。
このシナリオで考える
使うのは、顧客テーブルと注文テーブルの2つです。佐藤さんは注文が3件、鈴木さんは3件(うち2件は同じ日)、高橋さんは1件、田中さんは0件にしてあります。注文テーブルには、顧客IDと注文日の複合インデックスを貼っておきます。
CREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(20) NOT NULL
);
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT NOT NULL,
ordered_at DATE NOT NULL,
amount INT NOT NULL,
INDEX idx_customer_date (customer_id, ordered_at)
);
INSERT INTO customers VALUES
(1, '佐藤'), (2, '鈴木'), (3, '高橋'), (4, '田中');
INSERT INTO orders VALUES
(101, 1, '2026-10-01', 3000),
(102, 1, '2026-10-12', 1500),
(103, 1, '2026-10-20', 4200),
(104, 2, '2026-10-05', 2500),
(105, 2, '2026-10-18', 1800),
(106, 2, '2026-10-18', 6000),
(107, 3, '2026-10-25', 980);
LATERAL を付けないとエラーになる
ふつうの派生テーブルは、同じ FROM 句にあるほかのテーブルの列を参照できません。顧客ごとに新しい注文2件を取ろうとして、サブクエリの中に c.id を書くと、次のエラーになります。
SELECT c.name, o.ordered_at, o.amount
FROM customers AS c
JOIN (
SELECT ordered_at, amount
FROM orders
WHERE customer_id = c.id
ORDER BY ordered_at DESC
LIMIT 2
) AS o;
ERROR 1054 (42S22): Unknown column 'c.id' in 'where clause'
派生テーブルは、外側のクエリより先に1回だけ作られる決まりです。その時点では c の行がまだないので、c.id は見つかりません。この決まりをゆるめるのが LATERAL です。
JOIN LATERAL で顧客ごとに新しい注文2件を取る
派生テーブルの前に LATERAL と書くだけで、さきほどのクエリが動きます。並び順には、同じ日の注文に備えて id も足しておきます(理由は落とし穴の節で説明します)。
SELECT c.name, o.ordered_at, o.amount
FROM customers AS c
JOIN LATERAL (
SELECT ordered_at, amount
FROM orders
WHERE customer_id = c.id
ORDER BY ordered_at DESC, id DESC
LIMIT 2
) AS o
ORDER BY c.id, o.ordered_at DESC;
+--------+------------+--------+
| name | ordered_at | amount |
+--------+------------+--------+
| 佐藤 | 2026-10-20 | 4200 |
| 佐藤 | 2026-10-12 | 1500 |
| 鈴木 | 2026-10-18 | 6000 |
| 鈴木 | 2026-10-18 | 1800 |
| 高橋 | 2026-10-25 | 980 |
+--------+------------+--------+
顧客の行が1つ読まれるたびに、サブクエリがその顧客の c.id で動きます。LIMIT 2 は顧客ごとにかかるので、佐藤さんは3件のうち新しい2件だけが出ました。高橋さんは注文が1件なので1行です。田中さんは注文がなく、ふつうの JOIN と同じく結果から消えています。
LEFT JOIN LATERAL … ON TRUE で注文のない顧客も残す
注文が0件の顧客も一覧に出したいときは、LEFT JOIN LATERAL を使います。MySQL の LEFT JOIN は ON 句を省けません。結合の条件はサブクエリの WHERE に書いてあるので、ON には TRUE を書きます。
SELECT c.name, o.ordered_at, o.amount
FROM customers AS c
LEFT JOIN LATERAL (
SELECT ordered_at, amount
FROM orders
WHERE customer_id = c.id
ORDER BY ordered_at DESC, id DESC
LIMIT 2
) AS o ON TRUE
ORDER BY c.id, o.ordered_at DESC;
+--------+------------+--------+
| name | ordered_at | amount |
+--------+------------+--------+
| 佐藤 | 2026-10-20 | 4200 |
| 佐藤 | 2026-10-12 | 1500 |
| 鈴木 | 2026-10-18 | 6000 |
| 鈴木 | 2026-10-18 | 1800 |
| 高橋 | 2026-10-25 | 980 |
| 田中 | NULL | NULL |
+--------+------------+--------+
田中さんの行が NULL つきで残りました。LATERAL の派生テーブルを右側に置いて左のテーブルを参照するときに使える結合は、MySQL の公式リファレンスで次のように決まっています。
| 結合の書き方 | 右側に LATERAL を置けるか | 結果 |
|---|---|---|
| JOIN / INNER JOIN / CROSS JOIN / カンマ区切り | 置ける | サブクエリが0行の顧客は消える |
| LEFT JOIN … ON TRUE | 置ける | 0行の顧客も NULL で残る |
| RIGHT JOIN | 置けない | エラー |
RIGHT JOIN LATERAL で同じように c.id を参照すると、最初の例と同じ ERROR 1054(Unknown column ‘c.id’)になりました。
集計を何列もまとめて取る
LATERAL は集計にも使えます。顧客ごとの注文数・合計金額・最後の注文日を一度に取る使い方です。SELECT 句に書くスカラーサブクエリは1列しか返せないので、同じことをするとサブクエリを3つ書くことになります。LATERAL なら1つで済みます。
SELECT c.name, s.order_count, s.total, s.last_date
FROM customers AS c
LEFT JOIN LATERAL (
SELECT COUNT(*) AS order_count,
SUM(amount) AS total,
MAX(ordered_at) AS last_date
FROM orders
WHERE customer_id = c.id
) AS s ON TRUE
ORDER BY c.id;
+--------+-------------+-------+------------+
| name | order_count | total | last_date |
+--------+-------------+-------+------------+
| 佐藤 | 3 | 8700 | 2026-10-20 |
| 鈴木 | 3 | 10300 | 2026-10-18 |
| 高橋 | 1 | 980 | 2026-10-25 |
| 田中 | 0 | NULL | NULL |
+--------+-------------+-------+------------+
田中さんは注文数が0、合計が NULL になっています。COUNT は行がなくても0を返し、SUM は NULL を返すためです。合計を0で見せたいときは COALESCE(s.total, 0) と書きます。
ROW_NUMBER で書いた場合と比べる
同じ上位2件は、ウィンドウ関数の ROW_NUMBER でも取れます。全部の注文に顧客ごとの番号を振ってから、番号が2以下の行に絞る書き方です。
SELECT name, ordered_at, amount
FROM (
SELECT c.id AS cid, c.name, o.ordered_at, o.amount,
ROW_NUMBER() OVER (
PARTITION BY c.id
ORDER BY o.ordered_at DESC, o.id DESC
) AS rn
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
) AS t
WHERE rn <= 2
ORDER BY cid, ordered_at DESC;
+--------+------------+--------+
| name | ordered_at | amount |
+--------+------------+--------+
| 佐藤 | 2026-10-20 | 4200 |
| 佐藤 | 2026-10-12 | 1500 |
| 鈴木 | 2026-10-18 | 6000 |
| 鈴木 | 2026-10-18 | 1800 |
| 高橋 | 2026-10-25 | 980 |
+--------+------------+--------+
結果は LATERAL の JOIN 版と同じです。違うのは、処理の進め方と使える範囲です。
| 比べる点 | LATERAL | ROW_NUMBER |
|---|---|---|
| 使えるバージョン | MySQL 8.0.14 以降 | MySQL 8.0 以降 |
| 処理の進め方 | 顧客1行ごとにサブクエリを実行 | 結合した全行に番号を振ってから絞る |
| 0件の顧客を残す | LEFT JOIN … ON TRUE で書ける | LEFT JOIN にして、rn の条件に NULL を許す工夫が要る |
| 向いている場面 | 顧客を絞ってから、それぞれの注文を少しだけ取る | 全顧客の分をまとめて一度に出す |
LATERAL は顧客1人ずつにサブクエリを動かすので、(customer_id, ordered_at) のようなインデックスがあれば、顧客ごとに数行を読むだけで済みます。ROW_NUMBER や相関サブクエリを使った書き方は「グループごとの最新1件を取る3つのSQL(ROW_NUMBERほか)」で説明しています。
よくある落とし穴
同じ日付が並ぶと、どちらが出るか決まりません。鈴木さんには10月18日の注文が2件あります。並び順を ordered_at だけにして1件だけ取ると、今回は id 105 が返りました。
SELECT c.name, o.id, o.ordered_at, o.amount
FROM customers AS c
JOIN LATERAL (
SELECT id, ordered_at, amount
FROM orders
WHERE customer_id = c.id
ORDER BY ordered_at DESC
LIMIT 1
) AS o
WHERE c.id = 2;
+--------+-----+------------+--------+
| name | id | ordered_at | amount |
+--------+-----+------------+--------+
| 鈴木 | 105 | 2026-10-18 | 1800 |
+--------+-----+------------+--------+
同じ値の行どうしの順番は、MySQL が保証していません。インデックスやデータが変わると、106 が返ることもあります。ORDER BY ordered_at DESC, id DESC のように、最後に重ならない列を足して順番を1つに決めておきます。
サブクエリは顧客の行の数だけ動きます。EXPLAIN で見ると、次のようになりました。
+----+-------------------+------------+------+-------------------+-----------+------+----------------------------+
| id | select_type | table | type | key | ref | rows | Extra |
+----+-------------------+------------+------+-------------------+-----------+------+----------------------------+
| 1 | PRIMARY | c | ALL | NULL | NULL | 4 | Rematerialize (<derived2>) |
| 1 | PRIMARY | <derived2> | ALL | NULL | NULL | 2 | NULL |
| 2 | DEPENDENT DERIVED | orders | ref | idx_customer_date | demo.c.id | 1 | Using filesort |
+----+-------------------+------------+------+-------------------+-----------+------+----------------------------+
(見やすくするため、一部の列を省いています)
c の行の Extra にある Rematerialize は、c の行が変わるたびに派生テーブルを作り直す、という印です。select_type の DEPENDENT DERIVED も、外側の行に頼った派生テーブルであることを表します。orders は idx_customer_date を使った ref なので、顧客ごとに該当する行だけを読んでいます。このインデックスがないと、顧客1人ごとに注文テーブル全体を読むことになり、顧客が多いと一気に遅くなります。EXPLAIN の各列の読み方は「MySQLでEXPLAINを読む方法 — type列の意味を理解する」で説明しています。
ほかに、次の2点も押さえておくと迷いません。
- MySQL 8.0.14 より前(5.7 も含む)では LATERAL は構文エラーになります。本番のバージョンを SELECT VERSION(); で確かめてから使います
- JSON_TABLE() は、何も書かなくても LATERAL と同じ扱いになります。8.0.14 以降は、JSON_TABLE の前に LATERAL を書くとかえってエラーになります
PostgreSQL にも同じ書き方の LATERAL があります。SQL Server では CROSS APPLY と OUTER APPLY が同じ役目です。
まとめ
LATERAL を派生テーブルの前に付けると、サブクエリの中から左のテーブルの列を参照でき、顧客ごとの上位N件を ORDER BY と LIMIT で書けます。0件の顧客も残すなら LEFT JOIN LATERAL … ON TRUE を使い、RIGHT JOIN では使えません。並び順には id のような重ならない列を足し、サブクエリの WHERE に使う列にはインデックスを貼っておきます。使えるのは MySQL 8.0.14 以降です。
もっと学ぶなら【PR】
- 達人に学ぶSQL徹底指南書 第2版:CASE 式やウィンドウ関数など、一歩進んだ書き方がわかる中級者向けの本
※Amazonのアソシエイトとして、当サイトは適格販売により収入を得ています。
参考リンク
- MySQL 8.0 Reference Manual: Lateral Derived Tables
- MySQL 8.0 Reference Manual: Derived Tables
- PostgreSQL Documentation: LATERAL Subqueries
アイキャッチ画像: Photo by Thomas Kinto on Unsplash
