PostgreSQL: PostgreSQLの複数テーブル結合クエリ

最終更新:2026-08-26

1. 学習内容


2. ストーリー

CharlieはSaaSプラットフォームのバックエンドエンジニアです。プロダクトマネージャーからユーザー購入詳細レポートの作成を依頼されました。4つのテーブルを結合する必要があります:

ユーザーは複数の注文を持ち、各注文には複数の明細があり、各明細は1つの商品に対応します。Charlieは適切なJOIN種類を選択して、注文のないユーザーが欠落せず、予期しないデカルト積が発生しないようにしなければなりません。


3. 概念

(1) JOIN種類の概要

JOIN種類 意味 保持される側 代表的なケース
INNER JOIN 一致する行のみ保持 どちらも保持しない 一致必須
LEFT JOIN 左テーブルの全行を保持 メインテーブル + オプションの関連行
RIGHT JOIN 右テーブルの全行を保持 使用頻度は低い
FULL JOIN 両側の全行を保持 両方 差異の検出 / 突合
CROSS JOIN デカルト積 無条件 順列 / 組み合わせ
LATERAL JOIN サブクエリが左テーブルを参照 行ごとのTop-N
100%
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

両方のテーブルで一致する行のみを返します。

▶ サンプル: 注文のあるユーザーを検索

SQL
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;
TEXT 📖 参照専用
 user_id | name  | order_id | amount
---------+-------+----------+--------
       1 | Alice |      101 |  15000
       1 | Alice |      102 |   8000
       2 | Bob   |      201 |  25000

一度も注文したことのないユーザーは表示されません。

(3) LEFT JOIN

左テーブルの全行を保持し、右側に一致がない場合はNULLを埋めます。

▶ サンプル: 注文のないユーザーも含める

SQL
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;
TEXT 📖 参照専用
 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です。

▶ サンプル: 注文のないユーザーを見つける

SQL
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:

TEXT 📖 参照専用
 id | name     | value 
----+----------+-------
  1 | example  | 42
(1 row)

(4) RIGHT JOINとFULL JOIN

▶ サンプル: FULL JOINでユーザーと注文の差異を見つける

SQL
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;
TEXT 📖 参照専用
 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で孤立した注文を表示

SQL
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:

TEXT 📖 参照専用
 id | name     | value 
----+----------+-------
  1 | example  | 42
(1 row)
JOIN種類 動作 使用比較
LEFT JOIN 左テーブルの全行を保持 メイン + 関連を検索。左側の欠落を検出
RIGHT JOIN 右テーブルの全行を保持 左右を入れ替えたLEFT JOINに書き換え可能
FULL JOIN 両側の全行を保持 突合、差異の検出

(5) CROSS JOINとNATURAL JOIN

▶ サンプル: CROSS JOINですべての組み合わせを生成

SQL
SELECT
  d.department_name,
  p.project_name
FROM departments d
CROSS JOIN projects p;

Output:

TEXT 📖 参照専用
 id | name     | value 
----+----------+-------
  1 | example  | 42
(1 row)

3部門 × 5プロジェクト = 15行。

▶ サンプル: NATURAL JOIN(使用注意)

SQL
SELECT * FROM users
NATURAL JOIN orders;

Output:

TEXT 📖 参照専用
 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を使用

SQL
SELECT
  u.name,
  o.order_id,
  o.amount
FROM users u
JOIN orders o USING (user_id);

Output:

TEXT 📖 参照専用
 id | name     | value 
----+----------+-------
  1 | example  | 42
(1 row)

USINGは同名カラムを1つにマージします。SELECT *でuser_idが2回出力されることはありません。

(7) 自己結合

テーブルを自分自身と結合します。階層構造や同一テーブル内の比較に有用です。

▶ サンプル: 社員とマネージャーのペアリング

SQL
SELECT
  e.name AS employee,
  m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.employee_id;
TEXT 📖 参照専用
 employee | manager
----------+---------
 Alice    | David
 Bob      | Alice
 Charlie  | Alice
 David    |

▶ サンプル: 同じカテゴリ内の商品価格を比較

SQL
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:

TEXT 📖 参照専用
 id | name     | value 
----+----------+-------
  1 | example  | 42
(1 row)

4. 重要ポイント

(1) 複数テーブル結合(3テーブル以上)

Charlieの要件: users → orders → order_items → products を結合します。

▶ サンプル: 4テーブルのユーザー購入詳細

SQL
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;
TEXT 📖 参照専用
 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件の注文

SQL
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:

TEXT 📖 参照専用
CREATE TABLE
アプローチ 左テーブル参照可 Top-N対応 パフォーマンス
通常のサブクエリ 不可 ROW_NUMBERウィンドウ関数が必要 1回のスキャン
LATERAL 直接LIMIT サブクエリが行ごとに実行
ウィンドウ関�� ROW_NUMBER + フィルタ 1回のスキャン

▶ サンプル: 各商品の最新レビュー

SQL
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:

TEXT 📖 参照専用
CREATE TABLE

(3) JOINパフォーマンスの基本

▶ サンプル: EXPLAINで結合計画を表示

SQL
EXPLAIN
SELECT u.name, o.order_id
FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.amount > 50000;
TEXT 📖 参照専用
 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 既にソート済みのデータ 両側がソート済みである必要あり

▶ サンプル: インデックス不足による遅いクエリ

SQL
EXPLAIN ANALYZE
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.email = o.customer_email;

Output:

TEXT 📖 参照専用
 id | name     | value 
----+----------+-------
  1 | example  | 42
(1 row)

emailカラムにインデックスがない場合、Nested Loopのフルテーブルスキャンに低下する可能性があります。

▶ サンプル: JOINを高速化するインデックスを追加

SQL
CREATE INDEX idx_orders_user_id ON orders(user_id);

Output:

TEXT 📖 参照専用
CREATE TABLE

5. 実践

▶ サンプル: LEFT JOINでユーザーごとの注文数をカウント(0件を含む)

SQL
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:

TEXT 📖 参照専用
 count 
-------
     5
(1 row)

▶ サンプル: FULL JOINで2つのシステムのユーザーを突合

SQL
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:

TEXT 📖 参照専用
 id | name     | value 
----+----------+-------
  1 | example  | 42
(1 row)

▶ サンプル: 自己結合で同じ日に注文したユーザーを見つける

SQL
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:

TEXT 📖 参照専用
CREATE TABLE

6. 総合サンプル

Charlieのユーザー購入詳細レポート — 4テーブル結合 + 直近注文用LATERAL:

SQL
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;
TEXT 📖 参照専用
 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選択の決定木

100%
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

❓ よくある質問

Q LEFT JOINの後にWHEREで右テーブルのカラムをフィルタするとINNER JOINと同じになりますか?
A はい。WHERE o.col = 'x'は右テーブルのNULL行をフィルタするため、INNER JOINと同等になります。代わりに条件をON句に移動してください。
Q 複数テーブルJOINの順序は結果に影響しますか?
A INNER JOINの順序は結果に影響しません(論理的に等価)。ただしLEFT JOINの順序は影響します。左側が保持される側であり、自由に入れ替えることはできません。
Q USINGとONの違いは何ですか?
A USING(col)は同名カラムと等価結合が必要で、そのカラムを1つの出力カラムにマージします。ONはより柔軟で、異なるカラム名や複雑な条件をサポートします。
Q LATERALはサブクエリとどう違いますか?
A 通常のサブクエリは同じFROMレベルで左テーブルのカラムを参照できません。LATERALは参照可能で、実質的に左テーブルの行ごとに1回実行されるサブクエリです。
Q CROSS JOINの実際の用途は?
A 順列の生成(例: 日付 × 次元)、シーケンス生成、レポートマトリックスなどです。結果行数が2つのテーブルサイズの積になることに注意してください。
Q NATURAL JOINが推奨されない理由は?
A すべての同名カラムで暗黙的に結合するため、同名カラムの追加が結合ロジックを変更し、追跡困難なバグを引き起こします。安全性のためにONまたはUSINGを明示的に記述してください。

📖 まとめ


📝 練習問題

  1. ⭐ ordersとorder_itemsをINNER JOINし、注文ごとの合計数量(SUM quantity)を出力するクエリを記述してください。
  2. ⭐ LEFT JOINを使用して、一度も購入されたことのない商品を見つけてください(products LEFT JOIN order_items、NULLでフィルタ)。
  3. ⭐⭐ users → orders → order_items → productsを結合し、各ユーザーの購入詳細(商品名と行金額を含む)を出力してください。
  4. ⭐⭐ LATERAL JOINを使用して、各商品カテゴリ内で最も価格の高い2商品を見つけてください。
  5. ⭐⭐⭐ FULL JOINを使用して2つのシステムのユーザーテーブ��(system_a_users / system_b_users)を比較し、Aのみに存在、Bのみに存在、両方に存在するレコードをマークし、各カテゴリの件数をカウントするSQL文を記述してください。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%