PostgreSQL: PostgreSQLの複数テーブル結合クエリ
最終更新:2026-08-26
1. 学習内容
- INNER JOIN(内部結合)
- LEFT / RIGHT / FULL OUTER JOIN(外部結合)
- CROSS JOIN(クロス結合)
- NATURAL JOIN(使用注意)
- 自己結合
- USING省略構文
- 複数テーブル結合(3テーブル以上)
- PostgreSQL機能: LATERAL JOIN
- JOINパフォーマンスの基本(EXPLAIN入門)
2. ストーリー
CharlieはSaaSプラットフォームのバックエンドエンジニアです。プロダクトマネージャーからユーザー購入詳細レポートの作成を依頼されました。4つのテーブルを結合する必要があります:
- users — ユーザー情報
- orders — 注文
- order_items — 注文明細
- products — 商品
ユーザーは複数の注文を持ち、各注文には複数の明細があり、各明細は1つの商品に対応します。Charlieは適切なJOIN種類を選択して、注文のないユーザーが欠落せず、予期しないデカルト積が発生しないようにしなければなりません。
3. 概念
(1) JOIN種類の概要
| JOIN種類 | 意味 | 保持される側 | 代表的なケース |
|---|---|---|---|
| INNER JOIN | 一致する行のみ保持 | どちらも保持しない | 一致必須 |
| LEFT JOIN | 左テーブルの全行を保持 | 左 | メインテーブル + オプションの関連行 |
| RIGHT JOIN | 右テーブルの全行を保持 | 右 | 使用頻度は低い |
| FULL JOIN | 両側の全行を保持 | 両方 | 差異の検出 / 突合 |
| CROSS JOIN | デカルト積 | 無条件 | 順列 / 組み合わせ |
| LATERAL JOIN | サブクエリが左テーブルを参照 | — | 行ごとのTop-N |
flowchart LR
subgraph Inner
A1((A)) --- B1((B))
end
subgraph Left
A2((A)) --- B2((B))
A3((A)) -.-> B3((∅))
end
subgraph Full
A4((A)) --- B4((B))
A5((A)) -.-> B6((∅))
B5((∅)) -.-> A6((A))
end
style A1 fill:#c8e6c9
style B1 fill:#c8e6c9
style A2 fill:#c8e6c9
style A3 fill:#c8e6c9
style B2 fill:#c8e6c9
style A4 fill:#c8e6c9
style A5 fill:#c8e6c9
style B4 fill:#c8e6c9
(2) INNER JOIN
両方のテーブルで一致する行のみを返します。
▶ サンプル: 注文のあるユーザーを検索
SELECT
u.user_id,
u.name,
o.order_id,
o.amount
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id;
user_id | name | order_id | amount
---------+-------+----------+--------
1 | Alice | 101 | 15000
1 | Alice | 102 | 8000
2 | Bob | 201 | 25000
一度も注文したことのないユーザーは表示されません。
(3) LEFT JOIN
左テーブルの全行を保持し、右側に一致がない場合はNULLを埋めます。
▶ サンプル: 注文のないユーザーも含める
SELECT
u.user_id,
u.name,
o.order_id,
o.amount
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id;
user_id | name | order_id | amount
---------+---------+----------+--------
1 | Alice | 101 | 15000
1 | Alice | 102 | 8000
2 | Bob | 201 | 25000
3 | Charlie | |
Charlieには注文がないため、order_idとamountはNULLです。
▶ サンプル: 注文のないユーザーを見つける
SELECT u.user_id, u.name
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE o.order_id IS NULL;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
(4) RIGHT JOINとFULL JOIN
▶ サンプル: FULL JOINでユーザーと注文の差異を見つける
SELECT
u.user_id,
u.name,
o.order_id,
o.user_id AS order_user_id
FROM users u
FULL JOIN orders o ON u.user_id = o.user_id
ORDER BY u.user_id NULLS LAST, o.order_id NULLS LAST;
user_id | name | order_id | order_user_id
---------+---------+----------+---------------
1 | Alice | 101 | 1
2 | Bob | 201 | 2
3 | Charlie | |
| | 999 | 99
user_id=99の注文には一致するユーザーがなく、user_id=3のユーザーには注文がありません。両方の差異が確認できます。
▶ サンプル: RIGHT JOINで孤立した注文を表示
SELECT o.order_id, o.user_id, u.name
FROM users u
RIGHT JOIN orders o ON u.user_id = o.user_id
WHERE u.user_id IS NULL;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
| JOIN種類 | 動作 | 使用比較 |
|---|---|---|
| LEFT JOIN | 左テーブルの全行を保持 | メイン + 関連を検索。左側の欠落を検出 |
| RIGHT JOIN | 右テーブルの全行を保持 | 左右を入れ替えたLEFT JOINに書き換え可能 |
| FULL JOIN | 両側の全行を保持 | 突合、差異の検出 |
(5) CROSS JOINとNATURAL JOIN
▶ サンプル: CROSS JOINですべての組み合わせを生成
SELECT
d.department_name,
p.project_name
FROM departments d
CROSS JOIN projects p;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
3部門 × 5プロジェクト = 15行。
▶ サンプル: NATURAL JOIN(使用注意)
SELECT * FROM users
NATURAL JOIN orders;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
NATURAL JOINは両方のテーブルで同じ名前のカラムを自動的に結合します。危険性: 2つのテーブルに複数の同名カラムがある場合(例: created_at)、暗黙的に余分なAND条件が生成されます。明示的なONまたはUSINGを推奨します。
| 構文 | メリット | デメリット |
|---|---|---|
| NATURAL JOIN | 簡潔 | カラム名の変更が結合ロジックを変える可能性 |
| USING(col) | 簡潔かつ明示的 | 同名カラムの等価結合のみ |
| ON a.col = b.col | 完全な制御 | 冗長 |
(6) USING構文
▶ サンプル: ONの代わりにUSINGを使用
SELECT
u.name,
o.order_id,
o.amount
FROM users u
JOIN orders o USING (user_id);
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
USINGは同名カラムを1つにマージします。SELECT *でuser_idが2回出力されることはありません。
(7) 自己結合
テーブルを自分自身と結合します。階層構造や同一テーブル内の比較に有用です。
▶ サンプル: 社員とマネージャーのペアリング
SELECT
e.name AS employee,
m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.employee_id;
employee | manager
----------+---------
Alice | David
Bob | Alice
Charlie | Alice
David |
▶ サンプル: 同じカテゴリ内の商品価格を比較
SELECT
a.product_name AS product_a,
b.product_name AS product_b,
a.price - b.price AS price_diff
FROM products a
JOIN products b ON a.category_id = b.category_id
AND a.product_id < b.product_id
AND ABS(a.price - b.price) < 100;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
4. 重要ポイント
(1) 複数テーブル結合(3テーブル以上)
Charlieの要件: users → orders → order_items → products を結合します。
▶ サンプル: 4テーブルのユーザー購入詳細
SELECT
u.name AS user_name,
o.order_id,
o.created_at AS order_date,
p.product_name,
oi.quantity,
oi.unit_price,
oi.quantity * oi.unit_price AS line_total
FROM users u
JOIN orders o ON u.user_id = o.user_id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
ORDER BY u.name, o.order_id, oi.order_item_id;
user_name | order_id | order_date | product_name | quantity | unit_price | line_total
-----------+----------+----------------------+--------------+----------+------------+------------
Alice | 101 | 2025-03-15 10:30:00 | Widget Pro | 2 | 15000 | 30000
Alice | 101 | 2025-03-15 10:30:00 | Gadget Mini | 5 | 3000 | 15000
Alice | 102 | 2025-04-02 14:20:00 | Widget Pro | 1 | 15000 | 15000
Bob | 201 | 2025-05-10 09:00:00 | Server Rack | 1 | 80000 | 80000
(2) LATERAL JOIN(PostgreSQL機能)
LATERALを使用すると、サブクエリが左テーブルのカラムを参照できます。実質的に、サブクエリが左テーブルの行ごとに1回実行されます。
▶ サンプル: 各ユーザーの直近3件の注文
SELECT
u.name,
recent.order_id,
recent.amount,
recent.created_at
FROM users u
LEFT JOIN LATERAL (
SELECT o.order_id, o.amount, o.created_at
FROM orders o
WHERE o.user_id = u.user_id
ORDER BY o.created_at DESC
LIMIT 3
) recent ON true
ORDER BY u.name, recent.created_at DESC;
Output:
CREATE TABLE
| アプローチ | 左テーブル参照可 | Top-N対応 | パフォーマンス |
|---|---|---|---|
| 通常のサブクエリ | 不可 | ROW_NUMBERウィンドウ関数が必要 | 1回のスキャン |
| LATERAL | 可 | 直接LIMIT | サブクエリが行ごとに実行 |
| ウィンドウ関�� | — | ROW_NUMBER + フィルタ | 1回のスキャン |
▶ サンプル: 各商品の最新レビュー
SELECT
p.product_name,
r.review_text,
r.created_at
FROM products p
LEFT JOIN LATERAL (
SELECT review_text, created_at
FROM reviews r
WHERE r.product_id = p.product_id
ORDER BY created_at DESC
LIMIT 1
) r ON true;
Output:
CREATE TABLE
(3) JOINパフォーマンスの基本
▶ サンプル: EXPLAINで結合計画を表示
EXPLAIN
SELECT u.name, o.order_id
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.amount > 50000;
Hash Join
Hash Cond: (o.user_id = u.user_id)
-> Seq Scan on orders
Filter: (amount > 50000)
-> Hash
-> Seq Scan on users
| JOIN戦略 | 使用タイミング | 特徴 |
|---|---|---|
| Nested Loop | 小テーブルが大テーブル��駆動 | インデックス付き等価条件に有効 |
| Hash Join | 等価結合、インデックスなし | ハッシュテーブルを構築。大量データに適する |
| Merge Join | 既にソート済みのデータ | 両側がソート済みである必要あり |
▶ サンプル: インデックス不足による遅いクエリ
EXPLAIN ANALYZE
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.email = o.customer_email;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
emailカラムにインデックスがない場合、Nested Loopのフルテーブルスキャンに低下する可能性があります。
▶ サンプル: JOINを高速化するインデックスを追加
CREATE INDEX idx_orders_user_id ON orders(user_id);
Output:
CREATE TABLE
5. 実践
▶ サンプル: LEFT JOINでユーザーごとの注文数をカウント(0件を含む)
SELECT
u.user_id,
u.name,
COUNT(o.order_id) AS order_count
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
GROUP BY u.user_id, u.name
ORDER BY order_count DESC;
Output:
count
-------
5
(1 row)
▶ サンプル: FULL JOINで2つのシステムのユーザーを突合
SELECT
a.user_id AS system_a_id,
a.email AS system_a_email,
b.user_id AS system_b_id,
b.email AS system_b_email
FROM system_a_users a
FULL JOIN system_b_users b ON a.email = b.email
ORDER BY a.user_id NULLS LAST, b.user_id NULLS LAST;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
▶ サンプル: 自己結合で同じ日に注文したユーザーを見つける
SELECT DISTINCT
a.name AS user_a,
b.name AS user_b
FROM orders oa
JOIN users a ON oa.user_id = a.user_id
JOIN orders ob ON DATE(oa.created_at) = DATE(ob.created_at)
JOIN users b ON ob.user_id = b.user_id
WHERE a.user_id < b.user_id;
Output:
CREATE TABLE
6. 総合サンプル
Charlieのユーザー購入詳細レポート — 4テーブル結合 + 直近注文用LATERAL:
SELECT
u.name AS user_name,
u.email,
coalesce(order_summary.total_orders, 0) AS total_orders,
coalesce(order_summary.total_spent, 0) AS total_spent,
recent.order_id AS latest_order_id,
recent.created_at AS latest_order_date,
recent.amount AS latest_amount
FROM users u
LEFT JOIN LATERAL (
SELECT
COUNT(*) AS total_orders,
SUM(amount) AS total_spent
FROM orders o
WHERE o.user_id = u.user_id
) order_summary ON true
LEFT JOIN LATERAL (
SELECT order_id, created_at, amount
FROM orders o
WHERE o.user_id = u.user_id
ORDER BY created_at DESC
LIMIT 1
) recent ON true
ORDER BY total_spent DESC NULLS LAST;
user_name | email | total_orders | total_spent | latest_order_id | latest_order_date | latest_amount
-----------+--------------------+--------------+-------------+-----------------+-----------------------+--------------
Bob | bob@example.com | 5 | 320000 | 205 | 2025-11-20 16:00:00 | 85000
Alice | alice@example.com | 3 | 180000 | 102 | 2025-04-02 14:20:00 | 15000
Charlie | charlie@example.com| 0 | 0 | | |
7. JOIN選択の決定木
flowchart TD
A["複数テーブルの結合が必要?"] -->|"いいえ"| Z["JOIN不要"]
A -->|"はい"| B{"一致しない行を<br/>保持する必要がある?"}
B -->|"いいえ"| C["INNER JOIN"]
B -->|"はい、左側を保持"| D["LEFT JOIN"]
B -->|"はい、右側を保持"| E["RIGHT JOIN"]
B -->|"はい、両側を保持"| F["FULL JOIN"]
C --> G{"Top-Nまたは<br/>左カラム参照が必要?"}
G -->|"はい"| H["LATERAL JOIN"]
G -->|"いいえ"| I["通常のINNER JOIN"]
D --> G
style H fill:#c8e6c9
style F fill:#fff9c4
❓ よくある質問
📖 まとめ
- INNER JOINは一致する行のみ保持。LEFT JOINは左テーブルの全行を保持
- RIGHT JOINは左右を入れ替えたLEFT JOINに書き換え可能。FULL JOINは両側を保持
- CROSS JOINはデカルト積を生成。NATURAL JOINの暗黙的結合には注意が必要
- 自己結合は階層構造と同一テーブル内比較に使用。各テーブルに個別のエイリアスを付与
- USINGは同名カラムの等価結合を簡略化。ONは任意の条件をサポート
- LATERAL JOINは左テーブルのカラムを参照可能。Top-Nのケースに最適
- 複数テーブルはリレーションシップに沿って段階的に結合。LEFT JOINの順序に注意
- EXPLAINでJOIN戦略を確認。インデックスがパフォーマンスの鍵
📝 練習問題
- ⭐ ordersとorder_itemsをINNER JOINし、注文ごとの合計数量(SUM quantity)を出力するクエリを記述してください。
- ⭐ LEFT JOINを使用して、一度も購入されたことのない商品を見つけてください(products LEFT JOIN order_items、NULLでフィルタ)。
- ⭐⭐ users → orders → order_items → productsを結合し、各ユーザーの購入詳細(商品名と行金額を含む)を出力してください。
- ⭐⭐ LATERAL JOINを使用して、各商品カテゴリ内で最も価格の高い2商品を見つけてください。
- ⭐⭐⭐ FULL JOINを使用して2つのシステムのユーザーテーブ��(system_a_users / system_b_users)を比較し、Aのみに存在、Bのみに存在、両方に存在するレコードをマークし、各カテゴリの件数をカウントするSQL文を記述してください。