株式会社ブレインパッドプロダクトユニットでRtoaster GenAIの開発をしている依田です。
今回は「LATERAL句を使うと、複雑なサブクエリがシンプルに書ける」という話を、実行可能なサンプルSQLつきでお伝えします。
はじめに
データ分析でよく出てくるクエリのパターンに、「各グループの最新レコードを1件取得したい」「各カテゴリの上位N件だけ取り出したい」というものがあります。
こういった処理を書こうとすると、ウィンドウ関数や相関サブクエリを使った複雑なSQLになりがちです。LATERAL句を使うと、FROM句の左側の行ごとにサブクエリを実行できます。そのため、意図がはっきりした読みやすいSQLを書けます。
この記事で学べること
- LATERAL句の仕組みと使いどころ
- LATERAL未使用 vs LATERAL使用でどれだけ可読性が変わるか
-
CROSS JOIN LATERALとLEFT JOIN LATERALの使い分け
対象読者
- SQLをある程度書ける方(JOIN、サブクエリは知っている)
- 「ウィンドウ関数で書いたけど、もっとシンプルに書けないか」と感じたことがある方
環境情報
| 項目 | バージョン |
|---|---|
| PostgreSQL | 18.3 |
本記事のSQLは psql またはDBeaver等のGUIツールでそのまま実行できます。
サンプルデータの準備
ECサイトを想定したテーブルを2セット用意します。
ユースケース1用:ユーザーと注文
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
username TEXT NOT NULL
);
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id),
total_amount INTEGER NOT NULL,
ordered_at TIMESTAMP NOT NULL
);
INSERT INTO users (username) VALUES
('Alice'),
('Bob'),
('Carol');
INSERT INTO orders (user_id, total_amount, ordered_at) VALUES
(1, 3000, '2024-01-05 10:00:00'),
(1, 1500, '2024-02-14 12:30:00'),
(1, 4200, '2024-03-20 09:15:00'),
(2, 800, '2024-01-10 15:00:00'),
(2, 6700, '2024-03-01 11:00:00'),
(3, 2200, '2024-02-28 18:45:00');
ユースケース2用:カテゴリと商品
CREATE TABLE categories (
category_id SERIAL PRIMARY KEY,
category_name TEXT NOT NULL
);
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
category_id INTEGER NOT NULL REFERENCES categories(category_id),
product_name TEXT NOT NULL,
sales INTEGER NOT NULL
);
INSERT INTO categories (category_name) VALUES
('書籍'),
('家電'),
('食品');
INSERT INTO products (category_id, product_name, sales) VALUES
(1, 'SQL実践入門', 1200),
(1, 'PostgreSQL全機能バイブル', 980),
(1, 'データベース設計徹底指南', 750),
(1, 'プログラミングの原則', 430),
(2, 'ワイヤレスイヤホン', 3400),
(2, 'スマートスピーカー', 2900),
(2, 'Webカメラ', 2100),
(2, 'USBハブ', 1800),
(3, 'オーガニックコーヒー', 560),
(3, 'プロテインバー12本セット', 480),
(3, 'グリーンスムージー', 390);
ユースケース1:各ユーザーの最新注文を1件取得する
全ユーザーについて、「最後に注文した1件」だけを取り出したいケースです。
LATERALを使わない書き方
相関サブクエリ版
SELECT
u.user_id,
u.username,
o.order_id,
o.total_amount,
o.ordered_at
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.ordered_at = (
SELECT MAX(o2.ordered_at)
FROM orders o2
WHERE o2.user_id = u.user_id -- 外のテーブルを参照するために WHERE が必要
)
ORDER BY u.user_id;
WHERE o2.user_id = u.user_id の部分で外のクエリの値を使っていますが、この参照が WHERE の中に隠れているため、「なぜこのサブクエリが存在するのか」がパッと見では分かりにくいです。また、MAX で最大日付を取得してから再度 orders に戻って一致レコードを取るという二度手間感もあります。
ウィンドウ関数版
SELECT user_id, username, order_id, total_amount, ordered_at
FROM (
SELECT
u.user_id,
u.username,
o.order_id,
o.total_amount,
o.ordered_at,
ROW_NUMBER() OVER (PARTITION BY u.user_id ORDER BY o.ordered_at DESC) AS rn
FROM users u
JOIN orders o ON u.user_id = o.user_id
) ranked
WHERE rn = 1
ORDER BY user_id;
ウィンドウ関数で順番付けして、外側で WHERE rn = 1 でフィルタするパターンです。こちらは比較的よく使われますが、「なぜサブクエリが必要なのか」「rn = 1 は何を意味しているのか」を理解するには全体を読み込む必要があります。
LATERALを使った書き方
SELECT
u.user_id,
u.username,
latest_order.order_id,
latest_order.total_amount,
latest_order.ordered_at
FROM users u
LEFT JOIN LATERAL (
SELECT order_id, total_amount, ordered_at
FROM orders o
WHERE o.user_id = u.user_id -- u の値を直接参照できる
ORDER BY ordered_at DESC
LIMIT 1
) AS latest_order ON TRUE
ORDER BY u.user_id;
latest_order というサブクエリが「Aliceの最新注文を1件」という意図をそのまま表現しています。ORDER BY ordered_at DESC LIMIT 1 という直感的な書き方で「最新1件」を取れるため、SQLを読んだ瞬間に処理の意図が伝わります。
LEFT JOIN LATERAL ... ON TRUE という書き方は少し見慣れないかもしれませんが、「users の各行に対してサブクエリを実行し、0件でも行を残す(LEFT JOIN相当)」という意味です。
users テーブルに以下のSQLでユーザーを追加すると、LEFT JOIN 相当で動作することを確認できます。
INSERT INTO users (username) VALUES ('David');
実行結果
user_id | username | order_id | total_amount | ordered_at
---------+----------+----------+--------------+---------------------
1 | Alice | 3 | 4200 | 2024-03-20 09:15:00
2 | Bob | 5 | 6700 | 2024-03-01 11:00:00
3 | Carol | 6 | 2200 | 2024-02-28 18:45:00
ユースケース2:各カテゴリの売上上位3商品を取得する
全カテゴリについて、売上の多い商品を上位3件だけ取り出したいケースです。
LATERALを使わない書き方
ウィンドウ関数版
SELECT category_name, product_name, sales
FROM (
SELECT
c.category_name,
p.product_name,
p.sales,
ROW_NUMBER() OVER (PARTITION BY p.category_id ORDER BY p.sales DESC) AS rn
FROM categories c
JOIN products p ON c.category_id = p.category_id
) ranked
WHERE rn <= 3
ORDER BY category_name, sales DESC;
ユースケース1のウィンドウ関数版と同様に、サブクエリでランク付けしてから WHERE rn <= 3 でフィルタするパターンです。「上位3件」を取るためだけに、サブクエリを一層かぶせる必要があります。
LATERALを使った書き方
SELECT
c.category_name,
top_products.product_name,
top_products.sales
FROM categories c
CROSS JOIN LATERAL (
SELECT product_name, sales
FROM products p
WHERE p.category_id = c.category_id -- c の値を直接参照できる
ORDER BY sales DESC
LIMIT 3
) AS top_products
ORDER BY c.category_name, top_products.sales DESC;
top_products というサブクエリが「書籍カテゴリの上位3件」という処理をそのまま表現しています。ORDER BY sales DESC LIMIT 3 で「売上Top3」を直感的に表現できています。
こちらは CROSS JOIN LATERAL を使っています。ユースケース1の LEFT JOIN LATERAL との違いは次のとおりです。
| 構文 | 動作 |
|---|---|
CROSS JOIN LATERAL |
サブクエリが0件の場合、その行は結果から除外される |
LEFT JOIN LATERAL ... ON TRUE |
サブクエリが0件でも、外のテーブルの行はNULLとして残る |
商品が1件もないカテゴリを結果に含めたい場合は LEFT JOIN LATERAL を使います。今回は全カテゴリに商品があるため CROSS JOIN LATERAL で問題ありません。
実行結果
category_name | product_name | sales
---------------+--------------------------+-------
家電 | ワイヤレスイヤホン | 3400
家電 | スマートスピーカー | 2900
家電 | Webカメラ | 2100
書籍 | SQL実践入門 | 1200
書籍 | PostgreSQL全機能バイブル | 980
書籍 | データベース設計徹底指南 | 750
食品 | オーガニックコーヒー | 560
食品 | プロテインバー12本セット | 480
食品 | グリーンスムージー | 390
LATERAL句が特に便利なシーン まとめ
| シーン | LATERALなし | LATERALあり |
|---|---|---|
| 各グループの最新N件を取得 | ウィンドウ関数 + サブクエリ |
ORDER BY ... LIMIT N をそのまま書ける |
| 外のテーブルの値を条件に使いたい | 相関サブクエリの WHERE に隠れる |
サブクエリ内の WHERE で明示的に書ける |
| 集計結果を外のテーブルと組み合わせたい | 複雑なCTE(WITH句を用いた共通テーブル式)やサブクエリ |
集計 + JOIN を1つのLATERALにまとめられる |
LATERAL句は「可読性」を改善する手法であり、必ずしも性能が向上するわけではありません。
実行計画はデータ量やインデックス設計によって変わります。
まとめ
LATERAL句を使うと、「外のテーブルの各行に対してサブクエリを実行する」という処理を自然な形で書けます。
ウィンドウ関数で書いた複雑なクエリを見てモヤっとしたときは、LATERALで書き直してみると読みやすくなるかもしれません。