awesome-hacks
Docs

SQL JOIN 入門 — 内部結合と外部結合の違いと使い分け

INNER JOIN と OUTER JOIN(LEFT / RIGHT / FULL)の違いを整理し、どちらを使うべきか・INNER JOIN から OUTER JOIN に切り替えるべきケースを解説する

最終更新:2026/07/06

TL;DR

  • INNER JOIN(内部結合)は「両テーブルで一致した行だけ」を返す。一致しない行は結果から消える
  • OUTER JOIN(外部結合)は「一致しない行も NULL で補完して残す」。欠損を許容して全件見たいときに使う
  • INNER JOIN でデータが想定より少ない・消えていると感じたら、OUTER JOIN への切り替えを検討する合図

JOIN の基本的な考え方

SQL の JOIN は「2つのテーブルを条件で結びつけて1つの結果セットにする」操作である。
重要なのは 一致しない行をどう扱うか という点で、ここで INNER と OUTER が分かれる。

graph TD
    subgraph question ["問い:一致しない行をどう扱うか?"]
        A["2つのテーブルを結合"]
        A --> B{"一致しない行は?"}
        B -->|捨てる| C["INNER JOIN<br/>両テーブルで一致した行のみ"]
        B -->|残す| D["OUTER JOIN<br/>NULL で補完して残す"]
        D --> E["LEFT JOIN<br/>左テーブルを全行保持"]
        D --> F["RIGHT JOIN<br/>右テーブルを全行保持"]
        D --> G["FULL OUTER JOIN<br/>両テーブルを全行保持"]
    end

INNER JOIN(内部結合)

動作

結合条件に一致した行だけを返す。どちらかのテーブルに対応するレコードがなければ、その行は結果から除外される

-- ユーザーとその注文を取得する
SELECT u.id, u.name, o.item_name, o.price
FROM users AS u
INNER JOIN orders AS o ON u.id = o.user_id;
graph LR
    subgraph users ["users テーブル"]
        U1["id=1 田中"]
        U2["id=2 鈴木"]
        U3["id=3 佐藤 ← 注文なし"]
    end
    subgraph orders ["orders テーブル"]
        O1["user_id=1 商品A"]
        O2["user_id=1 商品B"]
        O3["user_id=2 商品C"]
    end
    subgraph result ["INNER JOIN 結果"]
        R1["田中 / 商品A"]
        R2["田中 / 商品B"]
        R3["鈴木 / 商品C"]
    end
    U1 --> R1
    U1 --> R2
    U2 --> R3

上図の例では「注文がない佐藤」は結果に含まれない。

使うべき場面

場面理由
紐づきが必ず存在することが保証されているデータを取得したい一致しない行は業務上あり得ないため無視してよい
集計クエリで NULL を混入させたくないOUTER JOIN 由来の NULL が SUM・COUNT に混入しない
パフォーマンスを優先したい結果セットが小さく、インデックスを使った Nested Loop Join が効きやすい

OUTER JOIN(外部結合)

動作

指定した方向のテーブルの全行を保持し、対応するレコードがない場合は NULL で補完して返す。

LEFT JOIN

左テーブル(FROM 側)の全行を保持する。最も頻繁に使われる OUTER JOIN。

-- 注文がないユーザーも含めて全ユーザーを取得する
SELECT u.id, u.name, o.item_name, o.price
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id;
graph LR
    subgraph users ["users テーブル(全行保持)"]
        U1["id=1 田中"]
        U2["id=2 鈴木"]
        U3["id=3 佐藤"]
    end
    subgraph orders ["orders テーブル"]
        O1["user_id=1 商品A"]
        O2["user_id=1 商品B"]
        O3["user_id=2 商品C"]
    end
    subgraph result ["LEFT JOIN 結果"]
        R1["田中 / 商品A"]
        R2["田中 / 商品B"]
        R3["鈴木 / 商品C"]
        R4["佐藤 / NULL ← 注文なし"]
    end
    U1 --> R1
    U1 --> R2
    U2 --> R3
    U3 --> R4

RIGHT JOIN

右テーブル(JOIN 側)の全行を保持する。LEFT JOIN で左右を入れ替えたものと等価なため、コードの可読性から LEFT JOIN が好まれることが多い。

FULL OUTER JOIN

両テーブルの全行を保持する。パフォーマンスへの影響が大きく、使い所は限られる。詳細は SQLのJOIN選択とパフォーマンス を参照。


INNER JOIN と OUTER JOIN の比較

観点INNER JOINLEFT JOIN(OUTER)
一致しない行除外されるNULL で補完して保持
結果の行数少ない(一致分のみ)多い(左テーブルの全行)
NULL の扱い発生しない右テーブル側が NULL になりうる
パフォーマンス高い(結果セットが小さい)やや低い(行数が増える)
主な用途必ず紐づきがある関係紐づきがない行も含めて見たい場合

INNER JOIN から OUTER JOIN に切り替えるべき場面

実務では「INNER JOIN で書いていたが、途中から OUTER JOIN に変える」判断を迫られることがある。以下のシグナルに気づいたら切り替えを検討する。

シグナル1:データが予期せず消えている

INNER JOIN は一致しない行を除外するため、「あるはずのデータが一覧に出ない」 というバグの原因になりやすい。

-- NG: 担当者未アサインのタスクが一覧から消える
SELECT t.id, t.title, u.name AS assignee
FROM tasks AS t
INNER JOIN users AS u ON t.assignee_id = u.id;
-- OK: 担当者未アサインのタスクも表示する
SELECT t.id, t.title, u.name AS assignee
FROM tasks AS t
LEFT JOIN users AS u ON t.assignee_id = u.id;
-- 未アサインの場合、assignee は NULL になる

「ユーザーが削除されたら、そのユーザーに紐づくタスクも一覧から消えてしまう」というバグも同じパターンである。

シグナル2:集計で「0件」のカテゴリが出てこない

INNER JOIN では結合対象がない行が消えるため、「注文が0件の月」や「在庫が0のカテゴリ」 が集計結果から抜け落ちる。

-- NG: 注文が0件の月がカウントに出てこない
SELECT DATE_TRUNC('month', o.created_at) AS month, COUNT(o.id) AS order_count
FROM orders AS o
INNER JOIN months AS m ON DATE_TRUNC('month', o.created_at) = m.month_start
GROUP BY month;
-- OK: 全月を LEFT JOIN の軸に置き、注文がない月は 0 で表示する
SELECT m.month_start, COUNT(o.id) AS order_count
FROM months AS m
LEFT JOIN orders AS o ON DATE_TRUNC('month', o.created_at) = m.month_start
GROUP BY m.month_start
ORDER BY m.month_start;

シグナル3:オプショナルなリレーションを JOIN している

外部キーが NULL 許容(nullable)のカラムに対して INNER JOIN を使うと、そのカラムが NULL のレコードがすべて除外される。スキーマの設計を見て外部キーが nullable なら OUTER JOIN が適切なことが多い。

-- articles.category_id は NULL を許容している
-- NG: カテゴリ未設定の記事が消える
SELECT a.title, c.name AS category
FROM articles AS a
INNER JOIN categories AS c ON a.category_id = c.id;
-- OK: カテゴリ未設定の記事も含める
SELECT a.title, c.name AS category
FROM articles AS a
LEFT JOIN categories AS c ON a.category_id = c.id;

シグナル4:削除フラグや論理削除との組み合わせ

論理削除(deleted_at IS NOT NULL など)されたレコードと INNER JOIN すると、削除済みレコードへの参照を持つ行が消える。「削除されたユーザーの注文履歴を保持したい」場合は OUTER JOIN に切り替える。

-- users に論理削除を実装している場合
-- NG: deleted_at が設定されたユーザーの注文が消える
SELECT o.id, o.item_name, u.name
FROM orders AS o
INNER JOIN users AS u ON o.user_id = u.id AND u.deleted_at IS NULL;
-- OK: 削除済みユーザーの注文も残し、ユーザー情報が取れない場合は NULL にする
SELECT o.id, o.item_name, u.name
FROM orders AS o
LEFT JOIN users AS u ON o.user_id = u.id AND u.deleted_at IS NULL;

使い分けの判断フロー

graph TD
    A["JOIN を書く前に確認"]
    A --> B{"紐づきがない行は<br/>結果に含める必要がある?"}
    B -->|不要| C["INNER JOIN を選ぶ"]
    B -->|必要| D{"どちらのテーブルを<br/>全行保持する?"}
    D -->|左テーブル| E["LEFT JOIN を選ぶ"]
    D -->|右テーブル| F["RIGHT JOIN<br/>(または左右入替で LEFT JOIN)"]
    D -->|両テーブル| G["FULL OUTER JOIN<br/>(パフォーマンス注意)"]
    C --> H{"外部キーが<br/>nullable か?"}
    H -->|YES| I["LEFT JOIN に変更を検討する"]
    H -->|NO| J["INNER JOIN で問題なし"]

よくある落とし穴

WHERE 句で OUTER JOIN を INNER JOIN に変えてしまう

LEFT JOIN で一致しない行を残しても、WHERE 句で右テーブルのカラムを条件に使うと NULL 行が除外され、実質 INNER JOIN になる。

-- NG: WHERE で右テーブルのカラムを条件にすると LEFT JOIN の効果がなくなる
SELECT u.id, u.name, o.item_name
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
WHERE o.price > 1000;  -- 注文がない行(o.price IS NULL)も除外される
-- OK: NULL を許容する場合は OR IS NULL を加えるか、条件を ON 句に移す
SELECT u.id, u.name, o.item_name
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id AND o.price > 1000;
-- ON 句に条件を書くと、price <= 1000 の注文は結合しないが、ユーザー行は残る

NULL 比較を = で行う

JOIN 結果の NULL カラムに対して WHERE column = NULL は常に偽になる。NULL チェックは必ず IS NULL / IS NOT NULL を使う。

-- NG: NULL との比較は常に偽
WHERE o.id = NULL

-- OK
WHERE o.id IS NULL

関連