LATERAL派生テーブルの使い方:顧客ごとの上位N件をSQLで取る

店のレシートの束 SQL

顧客ごとに新しい注文を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】

※Amazonのアソシエイトとして、当サイトは適格販売により収入を得ています。

参考リンク

アイキャッチ画像: Photo by Thomas Kinto on Unsplash

タイトルとURLをコピーしました