SQL JOIN 入門 — 内部結合と外部結合の違いと使い分け
INNER JOIN と OUTER JOIN(LEFT / RIGHT / FULL)の違いを整理し、どちらを使うべきか・INNER JOIN から OUTER JOIN に切り替えるべきケースを解説する
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 JOIN | LEFT 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