INNER JOIN は「両方の表に相手がいる行」だけを返し、LEFT JOIN は「左の表の全行」を残して、右に相手がいなければ右の列を NULL で埋めて返します。 使い分けの基準は「相手がいない行も結果に残したいか」の一点です。

-- INNER JOIN:注文のある顧客だけ(この記事のデータでは 3 行)
SELECT c.name, o.id, o.amount FROM customers c INNER JOIN orders o ON o.customer_id = c.id;

-- LEFT JOIN:注文のない顧客も残す(5 行。注文の列は NULL)
SELECT c.name, o.id, o.amount FROM customers c LEFT JOIN orders o ON o.customer_id = c.id;

JOINの種類の早見表(残る行・行数・使う場面)

顧客 4 行(customers)と注文 5 行(orders)を結合したときの行数です。データは次の節のものを使い、PostgreSQL 16・MySQL 8・SQLite 3.45 で同じ数になりました(FULL OUTER JOIN の無い MySQL は、UNION ALL の代替で同じ 7 行)。

JOIN の種類 残る行 行数 使う場面
INNER JOIN(JOIN と同じ) 両方に相手がいる行だけ 3 注文に顧客名を付けて一覧にする
LEFT JOIN(LEFT OUTER JOIN) 左の全行。右は相手がいれば値、いなければ NULL 5 顧客一覧に注文を付ける(注文のない顧客も出す)
RIGHT JOIN(RIGHT OUTER JOIN) 右の全行。左は相手がいなければ NULL 5 左右を入れ替えて LEFT JOIN に書き換える
FULL OUTER JOIN 左右の全行 7 2つの表の突き合わせ・差分(MySQL は UNION ALL で代替)
CROSS JOIN 全組み合わせ(4 × 5) 20 日付×商品などの土台表づくり
自己結合(同じ表を2回) 条件しだい 1 同じ顧客の注文の組、上司と部下、前の行との比較
NATURAL JOIN・USING 同名の列で結合する省略形 0 列名が同じときだけ。この例では id 同士で結合され 0 行

迷ったら、INNER JOIN か LEFT JOIN のどちらかです。 RIGHT JOIN は左右を入れ替えれば LEFT JOIN になり、FULL と CROSS は使う場面が限られます。

この記事と関連記事の分担

知りたいこと 読む記事
CROSS JOIN・FULL OUTER JOIN の詳しい使い方と MySQL での代替 SQL CROSS JOIN・FULL OUTER JOINの使い方と代替
自己結合(上司と部下・前の行との比較) SQL 自己結合とは|同じテーブルをJOINする書き方と上司部下・前行比較
EXISTS・サブクエリと JOIN の書き換え SQLサブクエリの使い方|相関サブクエリ・EXISTS・JOIN書き換え
LEFT JOIN で NULL になった列を 0 や文字で埋める SQL COALESCEの使い方|IFNULL・NVL・ISNULL対応表と実行結果
WITH 句で先に集計してから結合する SQL WITH句(CTE)の使い方|早見表・再帰クエリ・性能・エラー原文つき
JOIN した結果で別テーブルの値を UPDATE する SQL UPDATE文の書き方|複数列・別テーブル・CASE・DB別構文の早見表

確認環境:PostgreSQL 16.15(Ubuntu 24.04、psql)、MySQL 8.0.46(mysql クライアントを --default-character-set=utf8mb4 で実行)、SQLite 3.45.1(Python 3.13 の sqlite3 モジュール)で、この記事の SQL を実行し、出力をそのまま載せています。性能の節は、同じ DB に顧客 10 万行・注文 100 万行の表を作って EXPLAIN ANALYZE を取りました。JOIN の仕様は PostgreSQL 16 公式ドキュメント「Joined Tables」と MySQL 8.0 Reference Manual「JOIN Clause」「Hash Join Optimization」「EXPLAIN」、SQL Server と Oracle の構文は Microsoft Learn「FROM clause plus JOIN, APPLY, PIVOT (Transact-SQL)」と Oracle Database 23ai SQL Language Reference「Joins」で確かめました(確認日 2026年10月16日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SQL INNER JOINとLEFT JOINの違い|JOIN種類早見表と落とし穴」

この記事で使うテーブルとデータ(コピペ用)

顧客 customers と注文 orders の2表です。PostgreSQL・MySQL・SQLite のどれでも、このまま貼って実行できます。

DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS customers;
CREATE TABLE customers (
  id     INT PRIMARY KEY,
  name   VARCHAR(20) NOT NULL,
  region VARCHAR(10)
);
CREATE TABLE orders (
  id          INT PRIMARY KEY,
  customer_id INT,
  amount      INT NOT NULL,
  status      VARCHAR(10) NOT NULL
);
INSERT INTO customers (id, name, region) VALUES
  (1, '山田', '東京'),
  (2, '佐藤', '大阪'),
  (3, '鈴木', '東京'),
  (4, '高橋', NULL);
INSERT INTO orders (id, customer_id, amount, status) VALUES
  (101, 1,    1000, 'paid'),
  (102, 1,    2000, 'unpaid'),
  (103, 2,     500, 'paid'),
  (104, 9,     700, 'paid'),
  (105, NULL,  300, 'paid');
SELECT * FROM customers ORDER BY id;
 id | name | region
----+------+--------
  1 | 山田 | 東京
  2 | 佐藤 | 大阪
  3 | 鈴木 | 東京
  4 | 高橋 |
(4 rows)

SELECT * FROM orders ORDER BY id;
 id  | customer_id | amount | status
-----+-------------+--------+--------
 101 |           1 |   1000 | paid
 102 |           1 |   2000 | unpaid
 103 |           2 |    500 | paid
 104 |           9 |    700 | paid
 105 |             |    300 | paid
(5 rows)

結合の違いが見えるように、4つの仕掛けを入れてあります。

仕掛け 該当する行 何が見えるか
注文のない顧客 鈴木(id 3)・高橋(id 4) 左にしかない行の扱い
顧客のいない注文 注文 104(customer_id が 9) 右にしかない行の扱い
結合キーが NULL の注文 注文 105(customer_id が NULL) NULL は誰とも一致しない
注文が2件ある顧客 山田(注文 101・102) 1対多で行が増える

psql は NULL を空欄で表示します。MySQL は NULL と表示します(本文では両方の出力を載せています)。

INNER JOINとLEFT JOINの違い(同じ2表で全JOINを実行した結果)

結合条件 o.customer_id = c.id で相手が見つかる組は、山田と注文 101・102、佐藤と注文 103 の3組です。図の実線がその3組で、点線の行は相手がいません。INNER JOIN はこの実線の組だけを返し、LEFT JOIN は実線の組に「点線の顧客(鈴木・高橋)」を足し、RIGHT JOIN は「点線の注文(104・105)」を足し、FULL OUTER JOIN は両方を足します。

flowchart LR
  subgraph C["customers(左・4行)"]
    c1["1 山田"]
    c2["2 佐藤"]
    c3["3 鈴木"]
    c4["4 高橋"]
  end
  subgraph O["orders(右・5行)"]
    o101["101 customer_id 1"]
    o102["102 customer_id 1"]
    o103["103 customer_id 2"]
    o104["104 customer_id 9"]
    o105["105 customer_id NULL"]
  end
  c1 --- o101
  c1 --- o102
  c2 --- o103
  classDef alone stroke-dasharray: 5 5
  class c3,c4,o104,o105 alone

「左」「右」は、SQL の中で FROM に近いほうの表が左、JOIN の後ろに書いた表が右です。

INNER JOIN:両方に相手がいる行だけ(3行)

SELECT c.id, c.name, o.id AS order_id, o.amount
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id
ORDER BY c.id, o.id;
 id | name | order_id | amount
----+------+----------+--------
  1 | 山田 |      101 |   1000
  1 | 山田 |      102 |   2000
  2 | 佐藤 |      103 |    500
(3 rows)

注文のない鈴木・高橋と、顧客のいない注文 104・105 は、どちらも出てきません。INNER は省略でき、JOIN とだけ書いても同じ 3 行です(PostgreSQL 16 の公式ドキュメントにも「INNER と OUTER はどの形でも省略でき、INNER が既定」とあります)。

山田が 2 行になっている点は、あとの「1対多」の節で扱います。

LEFT JOIN:左の全行を残し、右は無ければNULL(5行)

SELECT c.id, c.name, o.id AS order_id, o.amount
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
ORDER BY c.id, o.id;
 id | name | order_id | amount
----+------+----------+--------
  1 | 山田 |      101 |   1000
  1 | 山田 |      102 |   2000
  2 | 佐藤 |      103 |    500
  3 | 鈴木 |          |
  4 | 高橋 |          |
(5 rows)

MySQL 8.0 では NULL がそのまま表示されます。

+----+--------+----------+--------+
| id | name   | order_id | amount |
+----+--------+----------+--------+
|  1 | 山田   |      101 |   1000 |
|  1 | 山田   |      102 |   2000 |
|  2 | 佐藤   |      103 |    500 |
|  3 | 鈴木   |     NULL |   NULL |
|  4 | 高橋   |     NULL |   NULL |
+----+--------+----------+--------+
5 rows in set
customers(左・4行)
idnameregion
1山田東京
2佐藤大阪
3鈴木東京
4高橋NULL
orders(右・5行)
idcustomer_idamountstatus
10111000paid
10212000unpaid
1032500paid
1049700paid
105NULL300paid
▼ INNER JOIN:(3 rows)/LEFT JOIN:(5 rows)
INNER JOIN の結果:3行
idnameorder_idamount
1山田1011000
1山田1022000
2佐藤103500

相手のいない鈴木・高橋と注文 104・105 は出ない

LEFT JOIN の結果:5行
idnameorder_idamount
1山田1011000
1山田1022000
2佐藤103500
3鈴木NULLNULL
4高橋NULLNULL

左の 4 人が全員残り、鈴木・高橋の右の列は NULL

左(customers)の 4 行は必ず残ります。 相手のいない鈴木・高橋は、右の列(order_id・amount)が NULL になります。右にしかない注文 104・105 は出てきません。LEFT OUTER JOIN と書いても同じ 5 行です。

公式ドキュメントの定義はこうです。「まず内部結合を行う。次に、T2 のどの行とも結合条件を満たさなかった T1 の各行について、T2 の列を NULL にした行を追加する。したがって結合結果には T1 の各行に対して必ず 1 行以上ある」(PostgreSQL 16「Joined Tables」より要約)。LEFT JOIN は「INNER JOIN の結果に、左の余りを NULL 付きで足したもの」 と覚えると、あとの COUNT や WHERE の落とし穴が理解しやすくなります。

RIGHT JOIN:右の全行を残す(5行)→ LEFT JOINに書き換える

SELECT c.id, c.name, o.id AS order_id, o.customer_id, o.amount
FROM customers c
RIGHT JOIN orders o ON o.customer_id = c.id
ORDER BY o.id;
 id | name | order_id | customer_id | amount
----+------+----------+-------------+--------
  1 | 山田 |      101 |           1 |   1000
  1 | 山田 |      102 |           1 |   2000
  2 | 佐藤 |      103 |           2 |    500
    |      |      104 |           9 |    700
    |      |      105 |             |    300
(5 rows)

今度は右(orders)の 5 行が全部残り、顧客のいない注文 104・105 の顧客の列が NULL になります。同じ結果は、表の順番を入れ替えた LEFT JOIN で書けます。

SELECT c.id, c.name, o.id AS order_id, o.customer_id, o.amount
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
ORDER BY o.id;
 id | name | order_id | customer_id | amount
----+------+----------+-------------+--------
  1 | 山田 |      101 |           1 |   1000
  1 | 山田 |      102 |           1 |   2000
  2 | 佐藤 |      103 |           2 |    500
    |      |      104 |           9 |    700
    |      |      105 |             |    300
(5 rows)

MySQL の公式ドキュメントも「DB 間で移植しやすいコードにするため、RIGHT JOIN ではなく LEFT JOIN を使うことを推奨する」と書いています。MySQL 8.0.46 で EXPLAIN FORMAT=TREE を取ると、RIGHT JOIN は内部で Nested loop left join に書き換えられていました(性能の節に実物があります)。「主役の表を FROM に書き、付け足す表を LEFT JOIN で足す」 と決めておくと、3 表以上になっても読み違えません。

FULL OUTER JOIN:左右の全行(7行)。MySQLはUNION ALLで代替

SELECT c.id, c.name, o.id AS order_id, o.customer_id, o.amount
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.id
ORDER BY c.id, o.id;
 id | name | order_id | customer_id | amount
----+------+----------+-------------+--------
  1 | 山田 |      101 |           1 |   1000
  1 | 山田 |      102 |           1 |   2000
  2 | 佐藤 |      103 |           2 |    500
  3 | 鈴木 |          |             |
  4 | 高橋 |          |             |
    |      |      104 |           9 |    700
    |      |      105 |             |    300
(7 rows)

一致した 3 行+左だけの 2 行+右だけの 2 行で 7 行です。MySQL 8.0 には FULL OUTER JOIN がなく、構文エラーになります。

ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'FULL OUTER JOIN orders o ON o.customer_id = c.id' at line 1

MySQL では LEFT JOIN と「右だけの行」を UNION ALL でつなぎます。MySQL 8.0.46 で同じ 7 行になりました。

SELECT c.id, c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
UNION ALL
SELECT c.id, c.name, o.id
FROM customers c
RIGHT JOIN orders o ON o.customer_id = c.id
WHERE c.id IS NULL;

UNION(ALL なし)でつなぐ書き方もありますが、同じ内容の行が 1 行にまとまって行数が減ることがあります。差分確認の書き方や PostgreSQL の FULL JOIN の条件の制約は、関連記事「SQL CROSS JOIN・FULL OUTER JOINの使い方と代替」で扱っています。

CROSS JOIN:全組み合わせ(20行)

SELECT c.name, o.id AS order_id
FROM customers c
CROSS JOIN orders o
ORDER BY c.id, o.id;
 name | order_id
------+----------
 山田 |      101
 山田 |      102
 山田 |      103
 山田 |      104
 山田 |      105
 佐藤 |      101
(中略:佐藤・鈴木・高橋も 5 行ずつ)
 高橋 |      105
(20 rows)

結合条件を書かず、4 × 5 = 20 通りの組み合わせを全部作ります。日付×商品の土台表を作って売上ゼロの日を埋める使い方は、関連記事「SQL CROSS JOIN・FULL OUTER JOINの使い方と代替」で扱っています。この記事であとで出てくる「ON を書き忘れた JOIN」は、MySQL ではこの CROSS JOIN と同じ 20 行になります。

自己結合・USING・NATURAL JOIN

自己結合は同じ表に別名を2つ付けて結合する書き方です。「同じ顧客の注文の組」を出す例です。

SELECT o1.id, o2.id, o1.customer_id
FROM orders o1
JOIN orders o2 ON o1.customer_id = o2.customer_id AND o1.id < o2.id;
 id  | id  | customer_id
-----+-----+-------------
 101 | 102 |           1
(1 row)

o1.id < o2.id が無いと、自分自身との組(101 と 101)や同じ組の裏返し(102 と 101)も出ます。上司と部下、前の行との比較は関連記事「SQL 自己結合とは|同じテーブルをJOINする書き方と上司部下・前行比較」で扱っています。

USING は、結合する列の名前が両方の表で同じときの省略形です。ON T1.a = T2.a を USING (a) と書け、結果にはその列が 1 回だけ現れます。NATURAL JOIN は「同名の列すべて」で USING を組む省略形です。customers と orders で同名の列は id(顧客 ID と注文 ID)なので、意図と違う列で結合されます。

SELECT c.name, o.id FROM customers c JOIN orders o USING (id);
SELECT c.name, o.id FROM customers c NATURAL JOIN orders o;
 name | id
------+----
(0 rows)

顧客 ID(1〜4)と注文 ID(101〜105)に同じ値が無いので 0 行です。エラーにならず、静かに空の結果が返ります。PostgreSQL の公式ドキュメントも「NATURAL は、どちらかの表に同名の列が増えるとその列でも結合してしまうため、USING よりかなり危険」と注意しています。列名が違うのが普通の業務テーブルでは、ON で列を明示する書き方が基本です。

行数のまとめ(PostgreSQL・MySQL・SQLiteで同じ)

SELECT 'INNER' AS kind, COUNT(*) FROM customers c INNER JOIN orders o ON o.customer_id = c.id
UNION ALL SELECT 'LEFT',  COUNT(*) FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
UNION ALL SELECT 'RIGHT', COUNT(*) FROM customers c RIGHT JOIN orders o ON o.customer_id = c.id
UNION ALL SELECT 'FULL',  COUNT(*) FROM customers c FULL JOIN orders o ON o.customer_id = c.id
UNION ALL SELECT 'CROSS', COUNT(*) FROM customers c CROSS JOIN orders o;
 kind  | count
-------+-------
 INNER |     3
 LEFT  |     5
 RIGHT |     5
 FULL  |     7
 CROSS |    20
(5 rows)
結合 行数 内訳
INNER 3 一致した組 3
LEFT 5 一致 3 + 左だけ 2(鈴木・高橋)
RIGHT 5 一致 3 + 右だけ 2(注文 104・105)
FULL 7 一致 3 + 左だけ 2 + 右だけ 2
CROSS 20 4 × 5

MySQL 8.0.46(FULL を除く)と SQLite 3.45.1 でも同じ行数でした。FULL = LEFT + RIGHT − INNER(5 + 5 − 3 = 7)で検算できます。

LEFT JOINの条件をONに書くかWHEREに書くかで結果が変わる

LEFT JOIN でいちばんつまずくのがここです。「支払い済み(status = 'paid')の注文だけを顧客に付けて、注文のない顧客も一覧に残したい」という例で、同じ条件を ON に書いた場合と WHERE に書いた場合を比べます。

flowchart TD
  S["customers 4行 と orders 5行"] --> ON["LEFT JOIN ... ON 結合条件 AND o.status = 'paid'"]
  ON --> R1["4行:鈴木・高橋は右が NULL のまま残る"]
  S --> J["LEFT JOIN ... ON 結合条件"]
  J --> W["WHERE o.status = 'paid'"]
  W --> R2["2行:右が NULL の行は条件に合わず消える"]
-- ON に書く:注文のない顧客も残る
SELECT c.name, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'
ORDER BY c.id;
 name | id
------+-----
 山田 | 101
 佐藤 | 103
 鈴木 |
 高橋 |
(4 rows)
-- WHERE に書く:注文のない顧客が消える
SELECT c.name, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'
ORDER BY c.id;
 name | id
------+-----
 山田 | 101
 佐藤 | 103
(2 rows)
ON に書く:4行
nameidstatus
山田101paid
佐藤103paid
鈴木NULLNULL
高橋NULLNULL

unpaid の注文 102 だけが結合相手から外れ、顧客は 4 人とも残る

WHERE に書く:2行
nameidstatus備考
山田101paid
山田102unpaid出ない
佐藤103paid
鈴木NULLNULL出ない
高橋NULLNULL出ない

結合後の 5 行のうち status が NULL の鈴木・高橋も条件に合わず消える

ON の条件は結合の前に、WHERE の条件は結合の後に評価されます。 WHERE に書いた場合、鈴木・高橋の行は o.status が NULL で、NULL = 'paid' は真にならないため落とされます。結果は INNER JOIN と同じになり、「LEFT JOIN と書いたのに行が消える」症状になります。PostgreSQL の公式ドキュメントも「ON の制約は結合の前、WHERE の制約は結合の後に処理される。内部結合では違いが無いが、外部結合では大きく違う」と説明しています。

条件を書く場所 意味 LEFT JOIN での効き方
ON どの行同士を組にするか(結合相手の絞り込み) 左の行は消えない。相手が減るだけ
WHERE 結合した結果からどの行を残すか 右が NULL の行は、右の列の条件で消える

INNER JOIN では、どちらに書いても同じ 2 行です(PostgreSQL 16 で両方を実行して確かめました)。

右の列の条件を WHERE に書くなら、NULL の行を明示して残す

結合後に絞りたい事情があるときは、OR 右.キー IS NULL を足します。

SELECT c.name, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid' OR o.id IS NULL
ORDER BY c.id;
 name | id
------+-----
 山田 | 101
 佐藤 | 103
 鈴木 |
 高橋 |
(4 rows)

ただし、この書き方は ON に書く場合と結果が変わることがあります。unpaid の注文しかない顧客(田中・注文 106 を一時的に足して確かめました)は、ON に書けば右が NULL の行として残って 5 行、この WHERE では o.status が unpaid で o.id も NULL でないため消えて 4 行になりました。「右の表に対する条件は ON に書く」を原則にすると迷いません。 左の表(customers)に対する条件は、WHERE に書いても行は消えません(WHERE c.region = '東京' で山田 2 行と鈴木 1 行の 3 行になりました)。

実行計画でも内部結合に変わる

100 万行の表で、ふつうの LEFT JOIN を PostgreSQL 16 で EXPLAIN すると Parallel Hash Right Join(外部結合)になります。右の列の条件を WHERE に書いた LEFT JOIN では、INNER JOIN と同じ Parallel Hash Join が選ばれました。プランナも「この書き方は内部結合と同じ」と判断しています。

EXPLAIN (COSTS OFF)
SELECT COUNT(*) FROM customers_big c LEFT JOIN orders_big o ON o.customer_id = c.id WHERE o.amount > 100;
               ->  Parallel Hash Join
                     Hash Cond: (o.customer_id = c.id)
                     ->  Parallel Seq Scan on orders_big o
                           Filter: (amount > 100)

SQL の各句がどの順に評価されるかは、関連記事「SQLの実行順序|書く順番と違う理由と、エラーになる書き方」で扱っています。

1対多のJOINで行が増える・SUMが二重になる

INNER JOIN の結果で山田が 2 行になったように、1対多の関係を結合すると「多」の側の件数ぶん「1」の側の行が繰り返されます。 これ自体は正しい動きですが、2 つ以上の「多」を同時に結合すると行が掛け算で増え、集計が二重になります。

入金テーブル payments を足して確かめます。山田は入金 2 件(1000 円・2000 円)、佐藤は 1 件(500 円)です。

CREATE TABLE payments (
  id          INT PRIMARY KEY,
  customer_id INT NOT NULL,
  paid        INT NOT NULL
);
INSERT INTO payments (id, customer_id, paid) VALUES
  (1, 1, 1000),
  (2, 1, 2000),
  (3, 2,  500);

注文と入金を同時に結合すると行が掛け算になる

SELECT c.name, o.id AS order_id, o.amount, p.id AS payment_id, p.paid
FROM customers c
JOIN orders   o ON o.customer_id = c.id
JOIN payments p ON p.customer_id = c.id
ORDER BY c.id, o.id, p.id;
 name | order_id | amount | payment_id | paid
------+----------+--------+------------+------
 山田 |      101 |   1000 |          1 | 1000
 山田 |      101 |   1000 |          2 | 2000
 山田 |      102 |   2000 |          1 | 1000
 山田 |      102 |   2000 |          2 | 2000
 佐藤 |      103 |    500 |          3 |  500
(5 rows)

山田は注文 2 件 × 入金 2 件 = 4 行です。この結果をそのまま集計すると、両方の合計が 2 倍になります。

SELECT c.name, SUM(o.amount) AS total_order, SUM(p.paid) AS total_paid
FROM customers c
JOIN orders   o ON o.customer_id = c.id
JOIN payments p ON p.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;
 name | total_order | total_paid
------+-------------+------------
 山田 |        6000 |       6000
 佐藤 |         500 |        500
(2 rows)
JOIN 後の行(SUM の前):5行
nameorder_idamountpaid
山田10110001000
山田10110002000
山田10220001000
山田10220002000
佐藤103500500

山田は注文 2 件 × 入金 2 件で 4 行になり、どの金額も 2 回ずつ現れる

▼ GROUP BY c.id, c.name で SUM:(2 rows)
SUM の結果:2行
nametotal_ordertotal_paid
山田60006000
佐藤500500

山田の注文合計・入金合計はどちらも 3000 だが、6000 と出る

正しくは注文合計 3000 円・入金合計 3000 円です。MySQL 8.0.46 でも同じ 6000 / 6000 になりました。エラーにならず、それらしい数字が出るのがこの不具合の厄介なところです。 DISTINCT を付けても、集計は増えた行に対して行われるので直りません(SELECT DISTINCT c.name, SUM(o.amount) ... でも 6000 のままでした)。

直し方:先に集計してから結合する

「多」の側をそれぞれ顧客ごとに集計してから結合します。

SELECT c.name, o.total_order, p.total_paid
FROM customers c
LEFT JOIN (SELECT customer_id, SUM(amount) AS total_order FROM orders   GROUP BY customer_id) o ON o.customer_id = c.id
LEFT JOIN (SELECT customer_id, SUM(paid)   AS total_paid  FROM payments GROUP BY customer_id) p ON p.customer_id = c.id
ORDER BY c.id;
 name | total_order | total_paid
------+-------------+------------
 山田 |        3000 |       3000
 佐藤 |         500 |        500
 鈴木 |             |
 高橋 |             |
(4 rows)

LEFT JOIN にしたので、注文も入金もない鈴木・高橋も残ります。相関サブクエリで書いても同じ結果です。

SELECT c.name,
       (SELECT SUM(amount) FROM orders   o WHERE o.customer_id = c.id) AS total_order,
       (SELECT SUM(paid)   FROM payments p WHERE p.customer_id = c.id) AS total_paid
FROM customers c
ORDER BY c.id;

集計した結果に名前を付けて使い回すなら WITH 句(CTE)が読みやすくなります。書き方は関連記事「SQL WITH句(CTE)の使い方|早見表・再帰クエリ・性能・エラー原文つき」で扱っています。

行が増えているかの確かめ方

確かめ方 SQL この記事のデータでの結果
結合キーの重複を見る SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id HAVING COUNT(*) > 1; customer_id 1 が 2 件(山田)
結合前後の件数を比べる SELECT COUNT(*) FROM customers; と結合後の COUNT(*) 4 行 → INNER JOIN で 3 行、LEFT JOIN で 5 行
主キーの重複を見る SELECT c.id, COUNT(*) FROM ... GROUP BY c.id HAVING COUNT(*) > 1 山田(id 1)が 2 行

「注文のある顧客だけ」のように絞り込みが目的なら、JOIN ではなく EXISTS を使うと行が増えません。

SELECT c.id, c.name FROM customers c JOIN orders o ON o.customer_id = c.id ORDER BY c.id;
 id | name
----+------
  1 | 山田
  1 | 山田
  2 | 佐藤
(3 rows)

SELECT c.id, c.name FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) ORDER BY c.id;
 id | name
----+------
  1 | 山田
  2 | 佐藤
(2 rows)

JOIN に DISTINCT を付けても 2 行にはなりますが、EXISTS は「増えた行を後から消す」のではなく最初から増やさない書き方です。EXISTS と JOIN の書き換えは関連記事「SQLサブクエリの使い方|相関サブクエリ・EXISTS・JOIN書き換え」で扱っています。

JOINキーがNULLの行は一致しない

注文 105 は customer_id が NULL です。INNER JOIN の結果に出てこなかったのは、顧客 ID に NULL が無いからではなく、NULL は何と比べても「真」にならないからです。NULL 同士でも一致しません。

SELECT a.v AS a, b.v AS b
FROM (VALUES (1), (NULL)) AS a(v)
JOIN (VALUES (1), (NULL)) AS b(v) ON a.v = b.v;
 a | b
---+---
 1 | 1
(1 row)

両方の表に NULL の行がありますが、組にはなりません。NULL 同士を一致させたいときは、PostgreSQL では IS NOT DISTINCT FROM、MySQL では <=> を使います。

-- PostgreSQL 16
... JOIN (VALUES (1), (NULL)) AS b(v) ON a.v IS NOT DISTINCT FROM b.v;
 a | b
---+---
 1 | 1
   |
(2 rows)

-- MySQL 8.0
... JOIN (VALUES ROW(1), ROW(NULL)) AS b(v) ON a.v <=> b.v;
+------+------+
| a    | b    |
+------+------+
|    1 |    1 |
| NULL | NULL |
+------+------+

業務データでは「地域が同じ顧客同士」のような結合で NULL が効いてきます。region が NULL の高橋は、誰とも組になりません。

SELECT a.name, b.name FROM customers a JOIN customers b ON a.region = b.region AND a.id < b.id;
 name | name
------+------
 山田 | 鈴木
(1 row)

結合キーに NULL が入りうる列を使うときは、NULL の行をどう扱うか(除くのか、別枠で数えるのか)を先に決めます。 NULL の比較の仕組みは関連記事「SQLのNULLの扱い|COALESCE・NULLIFと落とし穴12選」で扱っています。

相手がいない行を探す:LEFT JOIN + IS NULL・NOT EXISTS・NOT INの罠

「注文が 1 件もない顧客」のように、相手の表に存在しない行を探す書き方は 3 通りあります。結果はどれも鈴木・高橋の 2 行です。

-- 1. LEFT JOIN + IS NULL(MySQL 公式ドキュメントにも載っている形)
SELECT c.name FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

-- 2. NOT EXISTS
SELECT c.name FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);

-- 3. NOT IN(NULL に注意)
SELECT c.name FROM customers c
WHERE c.id NOT IN (SELECT customer_id FROM orders WHERE customer_id IS NOT NULL);
 name
------
 鈴木
 高橋
(2 rows)
LEFT JOIN の結果(WHERE の前):5行
nameorder_id備考
山田101出ない
山田102出ない
佐藤103出ない
鈴木NULL
高橋NULL
▼ WHERE o.id IS NULL:(2 rows)
結果:注文が 1 件もない顧客
name
鈴木
高橋

相手がいなかった行(右の列が NULL)だけが残る

NOT IN は、リストに NULL が1つでもあると0件になる

3 の WHERE customer_id IS NOT NULL を外すと、注文 105 の NULL がリストに入ります。

SELECT c.name FROM customers c WHERE c.id NOT IN (SELECT customer_id FROM orders);
 name
------
(0 rows)

PostgreSQL 16・MySQL 8.0.46(Empty set)・SQLite 3.45.1 のすべてで 0 件でした。id NOT IN (1, 1, 2, 9, NULL) は id <> 1 AND id <> 1 AND ... AND id <> NULL と同じ意味で、最後の id <> NULL が「不明」になるため、全体が真になりません。存在しない行を探すときは NOT EXISTS、または LEFT JOIN + IS NULL を使います。

書き方 リストに NULL があるとき 読みやすさ 補足
LEFT JOIN + WHERE 右.キー IS NULL 影響なし 結合の形で書ける 判定には右の主キーなど NULL にならない列を使う
NOT EXISTS 影響なし 「存在しない」が読み取りやすい 相関サブクエリ。基本はこれ
NOT IN 0 件になる 短い サブクエリ側で IS NOT NULL を付ける

実行計画はどちらも「anti join」になる

PostgreSQL 16 で 100 万行の表に対して 1 と 2 を EXPLAIN ANALYZE すると、1 は Parallel Hash Right Join に Filter: (o.id IS NULL) が付く形、2 は Parallel Hash Right Anti Join でした。実行時間は 178.8 ms と 182.8 ms で差はわずかです。MySQL 8.0.46 でも、1・2 の両方が Hash antijoin になりました。速さではなく、読みやすさで選びます。

LEFT JOINの件数:COUNT(*)とCOUNT(右の列)の差

顧客ごとの注文件数と合計を LEFT JOIN で数える例です。

SELECT c.name,
       COUNT(*)                  AS cnt_all,
       COUNT(o.id)               AS cnt_orders,
       COALESCE(SUM(o.amount), 0) AS total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;
 name | cnt_all | cnt_orders | total
------+---------+------------+-------
 山田 |       2 |          2 |  3000
 佐藤 |       1 |          1 |   500
 鈴木 |       1 |          0 |     0
 高橋 |       1 |          0 |     0
(4 rows)

注文のない鈴木・高橋が、COUNT(*) では 1 件になります。 LEFT JOIN で残った「右が NULL の行」も 1 行なので、COUNT(*) は行の数として数えます。COUNT(o.id) は NULL でない値の数なので 0 になります。

書き方 数えるもの 注文のない顧客
COUNT(*) 結合後の行数 1(NULL の行も 1 行)
COUNT(o.id) 右の列が NULL でない行数 0
SUM(o.amount) 値の合計。全部 NULL なら NULL NULL(COALESCE(..., 0) で 0 に)

MySQL 8.0.46 でも同じ結果でした。LEFT JOIN で件数を数えるときは、右の表の主キーを COUNT() に渡します。 集計関数と NULL の扱いは関連記事「SQL集計関数一覧|SUM・AVG・MAX・MIN・COUNTとNULLの扱い」で扱っています。NULL を 0 や文字で埋める COALESCE は関連記事「SQL COALESCEの使い方|IFNULL・NVL・ISNULL対応表と実行結果」で扱っています。

3表以上のJOINの順番:LEFT JOINの後ろにINNER JOINを書くと行が消える

JOIN は何個でも続けて書けますが、途中の LEFT JOIN で残した行は、その後ろの INNER JOIN で消えることがあります。 顧客 → 注文(LEFT JOIN)→ 入金(INNER JOIN)の例です。

SELECT c.name, o.id AS order_id, p.id AS payment_id
FROM customers c
LEFT JOIN orders   o ON o.customer_id = c.id
JOIN      payments p ON p.customer_id = o.customer_id
ORDER BY c.id, o.id, p.id;
 name | order_id | payment_id
------+----------+------------
 山田 |      101 |          1
 山田 |      101 |          2
 山田 |      102 |          1
 山田 |      102 |          2
 佐藤 |      103 |          3
(5 rows)

鈴木・高橋は LEFT JOIN では残っていましたが、o.customer_id が NULL なので入金との INNER JOIN の条件を満たせず、消えました。結合は上から順に「(customers と orders の結果)と payments」のように評価されるためです。

直し方は 2 つあります。 1 つ目は、後ろの結合も LEFT JOIN にすることです。

SELECT c.name, o.id AS order_id, p.id AS payment_id
FROM customers c
LEFT JOIN orders   o ON o.customer_id = c.id
LEFT JOIN payments p ON p.customer_id = o.customer_id
ORDER BY c.id, o.id, p.id;
 name | order_id | payment_id
------+----------+------------
 山田 |      101 |          1
 山田 |      101 |          2
 山田 |      102 |          1
 山田 |      102 |          2
 佐藤 |      103 |          3
 鈴木 |          |
 高橋 |          |
(7 rows)

2 つ目は、「注文と入金」を先に INNER JOIN で組にしてから、顧客に LEFT JOIN する書き方です。かっこで囲むと、結合の順番を変えられます。

SELECT c.name, o.id AS order_id, p.id AS payment_id
FROM customers c
LEFT JOIN (orders o JOIN payments p ON p.customer_id = o.customer_id) ON o.customer_id = c.id
ORDER BY c.id, o.id, p.id;

PostgreSQL 16・MySQL 8.0.46 のどちらも、1 つ目と同じ 7 行になりました。

書き方 残る行 向いている場面
LEFT → INNER 後ろの INNER で相手がいない行が消える 「注文も入金もある顧客だけ」のとき
LEFT → LEFT 左の全行が残る 一覧に付け足していくとき(基本形)
LEFT →(INNER)をかっこで 「注文と入金がそろった組」だけ付けて、顧客は全部残す 組になった情報だけを付けたいとき

3 表以上では、主役の表を FROM に置き、付け足す表はすべて LEFT JOIN で書くと、どこで行が消えるかを考えずに済みます。

JOINのエラー文と直し方(MySQL・PostgreSQL原文)

この記事のテーブルで実際に出したエラーです。

エラー文(原文) 原因 直し方
MySQL ERROR 1052 (23000): Column 'id' in field list is ambiguous/PostgreSQL ERROR: column reference "id" is ambiguous/SQLite ambiguous column name: id 両方の表にある列名(id)を、表名を付けずに書いた c.id・o.id のように別名を付ける
PostgreSQL ERROR: missing FROM-clause entry for table "orders" FROM・JOIN に無い表を WHERE や SELECT で参照した その表を JOIN に加える
PostgreSQL ERROR: invalid reference to FROM-clause entry for table "customers"(HINT: Perhaps you meant to reference the table alias "c".)/MySQL ERROR 1054 (42S22): Unknown column 'customers.id' in 'on clause' 別名を付けた表を、元の表名で参照した 別名(c)で書く
MySQL ERROR 1054 (42S22): Unknown column 'o.amont' in 'where clause'/PostgreSQL ERROR: column o.amont does not exist(HINT: Perhaps you meant to reference the column "o.amount".) 列名の誤り 列名を直す
MySQL ERROR 1064 (42000): You have an error in your SQL syntax; ... near 'FULL OUTER JOIN orders o ON o.customer_id = c.id' at line 1 MySQL に FULL OUTER JOIN が無い LEFT JOIN + RIGHT JOIN(WHERE 左.キー IS NULL)を UNION ALL でつなぐ
MySQL ERROR 1054 (42S22): Unknown column 'customers.id' in 'on clause'(customers FULL JOIN orders ON customers.id = ... と書いた) MySQL が FULL を customers の別名として読み、内部結合として処理した(ON FULL.id = ... と書くと通ってしまい、Warning 4119: Using FULL as unquoted identifier is deprecated が出る) FULL JOIN を書かず、上の代替にする
PostgreSQL ERROR: syntax error at or near ";"(JOIN orders o; で終わっている) INNER JOIN の ON を書き忘れた。PostgreSQL の SELECT 構文では INNER・OUTER 結合に ON・USING・NATURAL のいずれかが必須 ON を書く。MySQL と SQLite はエラーにならず、全組み合わせ(20 行)が返る
PostgreSQL ERROR: syntax error at or near "ON" CROSS JOIN に ON を付けた INNER JOIN にするか、ON を外す
MySQL ERROR 1064 (42000): ... near '' at line 1 LEFT JOIN の ON を書き忘れた(MySQL は LEFT JOIN には ON が必要) ON を書く
MySQL ERROR 1064 (42000): ... near 'INNERJOIN orders o ON o.customer_id = c.id' at line 1/PostgreSQL ERROR: syntax error at or near "INNERJOIN" INNER JOIN のスペースが無い INNER JOIN・LEFT JOIN と 2 語で書く
MySQL ERROR 1054 (42S22): Unknown column 'c.id' in 'on clause'/PostgreSQL ERROR: invalid reference to FROM-clause entry for table "c"(DETAIL: There is an entry for table "c", but it cannot be referenced from this part of the query.) FROM customers c, orders o JOIN customers c2 ON c2.id = c.id のように、カンマ区切りと JOIN を混ぜた。JOIN はカンマより強く結び付くため、ON から c が見えない カンマ区切りをやめ、全部 JOIN で書く

MySQL の公式ドキュメントは、カンマと JOIN の優先順位について「t1, t2 JOIN t3 は (t1, (t2 JOIN t3)) と解釈される」と説明し、Unknown column 't1.i1' in 'on clause' の例を載せています。PostgreSQL 側にも「JOIN はカンマより強く結合する」という同じ注意があります。

やってはいけない書き方と、JOINでDELETE・UPDATEする前の手順

JOIN の事故は、エラーにならずに「それらしい結果」が出るものがほとんどです。

書き方 起きること 代わりに
LEFT JOIN の右の列の条件を WHERE に書く 相手のいない行が消え、INNER JOIN と同じ結果になる 右の列の条件は ON に書く
「多」の表を 2 つ以上同時に JOIN して SUM 行が掛け算で増え、合計が 2 倍・4 倍になる 先に集計してから結合する
LEFT JOIN の件数を COUNT(*) で数える 相手のいない行が 1 件になる COUNT(右.主キー)
NOT IN (SELECT ...) で存在しない行を探す リストに NULL があると 0 件 NOT EXISTS か LEFT JOIN + IS NULL
ON を書き忘れる(MySQL・SQLite) 全組み合わせになり、行数が左 × 右に膨らむ 結合は必ず ON で書く。全組み合わせは CROSS JOIN と明記
NATURAL JOIN を使う 同名の列すべてで結合され、列が増えると結果が変わる ON か USING で列を明示
JOIN した結果を SELECT * で返す 同名の列(id)が 2 つ並び、プログラム側でどちらか分からない 列を別名付きで列挙する
LEFT JOIN の後ろに INNER JOIN を書く LEFT JOIN で残した行が消える 後ろも LEFT JOIN にするか、かっこで先に組にする
結合キーに NULL が入る列を使う NULL の行は誰とも組にならない 扱いを決めて IS NULL で別に出す

JOIN の結果で DELETE・UPDATE する前に、同じ条件の SELECT と BEGIN

JOIN の条件を間違えたまま DELETE や UPDATE を実行すると、想定外の行が書き換わります。SELECT は何度でもやり直せますが、COMMIT した DELETE は戻りません。「顧客のいない注文を消す」作業を、PostgreSQL 16 で安全な手順どおりに実行した結果です。

-- 1. 消す対象を同じ結合条件で SELECT し、件数を控える
SELECT o.id, o.customer_id
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE c.id IS NULL;
 id  | customer_id
-----+-------------
 104 |           9
 105 |
(2 rows)

-- 2. トランザクションの中で実行する
BEGIN;
DELETE FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM customers c WHERE c.id = o.customer_id);
DELETE 2

-- 3. 件数が手順 1 と同じ(2)なら COMMIT、違えば ROLLBACK
ROLLBACK;
SELECT COUNT(*) FROM orders;
 count
-------
     5
(1 row)

ROLLBACK したので 5 行とも残っています。同じ作業を NOT IN (SELECT id FROM customers) で書くと、DELETE 1 になりました。customer_id が NULL の注文 105 は NULL NOT IN (...) が真にならず、消えずに残ります。意図がどちらであっても、手順 1 の SELECT と件数を突き合わせることで気づけます。 MySQL では DELETE o FROM orders o LEFT JOIN customers c ON c.id = o.customer_id WHERE c.id IS NULL; の形で、START TRANSACTION → Query OK, 2 rows affected → ROLLBACK で 5 行に戻ることを確かめました。JOIN した値で UPDATE する書き方は、関連記事「SQL UPDATE文の書き方|複数列・別テーブル・CASE・DB別構文の早見表」で扱っています。

JOINの性能:JOIN列のインデックスとEXPLAINの実物(Nested Loop・Hash Join)

顧客 10 万行(customers_big)・注文 100 万行(orders_big、64 MB)を作り、結合列 orders_big.customer_id にインデックスが無いときとあるときの実行計画を取りました。

1 人の顧客の注文を取る:インデックスで 42.7 ms → 0.39 ms(PostgreSQL 16)

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT c.name, o.id, o.amount
FROM customers_big c
JOIN orders_big o ON o.customer_id = c.id
WHERE c.id = 123;

-- インデックスなし
 Nested Loop (actual rows=8 loops=1)
   ->  Index Scan using customers_big_pkey on customers_big c (actual rows=1 loops=1)
         Index Cond: (id = 123)
   ->  Gather (actual rows=8 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Parallel Seq Scan on orders_big o (actual rows=3 loops=3)
               Filter: (customer_id = 123)
               Rows Removed by Filter: 333331
 Execution Time: 42.732 ms

-- CREATE INDEX idx_orders_big_customer_id ON orders_big (customer_id); のあと
 Nested Loop (actual rows=8 loops=1)
   ->  Index Scan using customers_big_pkey on customers_big c (actual rows=1 loops=1)
         Index Cond: (id = 123)
   ->  Bitmap Heap Scan on orders_big o (actual rows=8 loops=1)
         Recheck Cond: (customer_id = 123)
         ->  Bitmap Index Scan on idx_orders_big_customer_id (actual rows=8 loops=1)
               Index Cond: (customer_id = 123)
 Execution Time: 0.388 ms

8 件の注文を取るだけなのに、インデックスが無いと 100 万行を読んで 999,992 行を捨てています(Rows Removed by Filter)。PostgreSQL は、外部キー制約を付けても参照する側の列(customer_id)にインデックスを自動では作りません(公式ドキュメント「Foreign Keys」に「外部キー制約の宣言は参照列のインデックスを自動作成しない」とあります)。MySQL の InnoDB は外部キー制約を付けると参照する側に自動でインデックスを作りますが、この記事の orders_big のように制約を付けていない列には作られません。JOIN が遅いときは、まず結合列のインデックスの有無を確かめます。

MySQL 8.0.46 でも同じ傾向でした(EXPLAIN ANALYZE、8.0.18 以降で使えます)。

-- インデックスなし
-> Filter: (o.customer_id = 123)  (cost=100354 rows=99849) (actual time=5.42..415 rows=10 loops=1)
    -> Table scan on o  (cost=100354 rows=998490) (actual time=1.35..340 rows=1e+6 loops=1)

-- インデックスあり
-> Covering index lookup on o using idx_orders_big_customer_id (customer_id=123)  (cost=2.01 rows=10) (actual time=0.00607..0.00981 rows=10 loops=1)

全件を結合して集計する:Hash Join が選ばれ、インデックスでは速くならない

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT c.name, SUM(o.amount)
FROM customers_big c
JOIN orders_big o ON o.customer_id = c.id
GROUP BY c.id, c.name;

-- インデックスなし(抜粋)
                     ->  Hash Join (actual rows=333333 loops=3)
                           Hash Cond: (o.customer_id = c.id)
                           ->  Parallel Seq Scan on orders_big o (actual rows=333333 loops=3)
                           ->  Hash (actual rows=100000 loops=3)
                                 ->  Seq Scan on customers_big c (actual rows=100000 loops=3)
 Execution Time: 657.581 ms

-- インデックスあり(抜粋)
   ->  Merge Join (actual rows=1000000 loops=1)
         Merge Cond: (c.id = o.customer_id)
         ->  Index Scan using customers_big_pkey on customers_big c (actual rows=100000 loops=1)
         ->  Index Scan using idx_orders_big_customer_id on orders_big o (actual rows=1000000 loops=1)
 Execution Time: 753.780 ms

全行を結合するときは、PostgreSQL は小さい表(customers_big)をメモリ上のハッシュ表にして大きい表を 1 回読む Hash Join を選びました。インデックスを作ると Merge Join に変わりましたが、時間は 657 ms → 754 ms で速くなっていません。結合列のインデックスが効くのは「一部の行に絞ってから結合する」ときで、全件を結合する処理では読み方そのものが違います。

結合の方法 仕組み 向いている場面
Nested Loop 外側の表の各行について、内側の表を探す 外側が少数行で、内側の結合列にインデックスがある
Hash Join 片方の表をハッシュ表にし、もう片方を 1 回読む 大きな表同士の等号結合。インデックス不要
Merge Join 両方を結合列で並べ替えて突き合わせる 両方がすでに並んでいる(インデックスがある)とき

MySQL 8.0 は 8.0.18 からハッシュ結合を使い、8.0.20 からは外部結合や NOT EXISTS(antijoin)にも使います(公式ドキュメント「Hash Join Optimization」)。EXPLAIN FORMAT=TREE(8.0.16 以降)で、この記事の 4 行 × 5 行の表に対する計画を取ると、結合の種類ごとに次のように出ました。

-- INNER JOIN
-> Nested loop inner join  (cost=2.5 rows=5)
    -> Filter: (o.customer_id is not null)  (cost=0.75 rows=5)
        -> Table scan on o  (cost=0.75 rows=5)
    -> Single-row index lookup on c using PRIMARY (id=o.customer_id)  (cost=0.27 rows=1)

-- LEFT JOIN
-> Left hash join (o.customer_id = c.id)  (cost=2.44 rows=20)
    -> Table scan on c  (cost=0.65 rows=4)
    -> Hash
        -> Table scan on o  (cost=0.188 rows=5)

-- RIGHT JOIN(内部で LEFT JOIN に書き換えられている)
-> Nested loop left join  (cost=2.5 rows=5)
    -> Table scan on o  (cost=0.75 rows=5)
    -> Single-row index lookup on c using PRIMARY (id=o.customer_id)  (cost=0.27 rows=1)

-- LEFT JOIN + WHERE o.id IS NULL
-> Filter: (o.id is null)  (cost=2.84 rows=4)
    -> Hash antijoin (o.customer_id = c.id)  (cost=2.84 rows=4)

INNER JOIN の計画に Filter: (o.customer_id is not null) が入っているのは、NULL のキーは一致しないと MySQL が知っていて、先に捨てているからです。MySQL の EXPLAIN の読み方は関連記事「MySQL EXPLAINの見方|type・key・rows・Extra早見表」で扱っています。インデックスが使われない書き方は関連記事「SQLのインデックスが効かない10のパターンと確認方法」で扱っています。

INNER JOIN と LEFT JOIN はどちらが速いか

100 万行の表で COUNT(*) を取った時間は、INNER JOIN が 229 ms、LEFT JOIN が 246 ms でした(PostgreSQL 16、結合列にインデックスあり)。差はわずかで、実行計画はどちらもハッシュ結合(Parallel Hash Join と Parallel Hash Right Join)でした。結果の行が違う(100 万行と 100 万 6 行)ので、速さで選ぶものではなく、「相手がいない行が必要か」で選びます。

どのJOINを使うかの判断表

flowchart TD
  Q1{"全組み合わせが欲しい?"} -- はい --> X["CROSS JOIN"]
  Q1 -- いいえ --> Q2{"相手がいない行も残す?"}
  Q2 -- 残さない --> I["INNER JOIN"]
  Q2 -- 残す --> Q3{"どちら側の行を残す?"}
  Q3 -- 左だけ --> L["LEFT JOIN"]
  Q3 -- 右だけ --> R["RIGHT JOIN(LEFT に書き換え可)"]
  Q3 -- 両方 --> F["FULL OUTER JOIN(MySQL は UNION ALL)"]
知りたいこと 選ぶ書き方 この記事のデータでの行数
両方にあるデータだけ INNER JOIN 3
左の表を全部出し、右は「あれば付ける」 LEFT JOIN 5
右の表を全部出したい 左右を入れ替えて LEFT JOIN 5
左右どちらかにしかない行も含めて全部 FULL OUTER JOIN(MySQL は UNION ALL + IS NULL) 7
左にしかない行だけ(注文のない顧客) LEFT JOIN + WHERE 右.キー IS NULL、または NOT EXISTS 2
2 つの表で食い違う行だけ FULL OUTER JOIN + WHERE 左.キー IS NULL OR 右.キー IS NULL 4
相手がいるかどうかで絞るだけ(行を増やさない) EXISTS 2
全組み合わせの土台(日付×商品など) CROSS JOIN 20
顧客ごとの件数・合計 LEFT JOIN + COUNT(右.主キー)、「多」が 2 つ以上なら先に集計 4

DB別の違い(MySQL・PostgreSQL・SQLite・SQL Server・Oracle)

PostgreSQL・MySQL・SQLite の欄はこの記事の実行結果、SQL Server と Oracle の欄は公式ドキュメントにもとづきます。

DB FULL OUTER JOIN RIGHT JOIN 補足
PostgreSQL 16.15 使える 使える ON の無い JOIN、ON 付きの CROSS JOIN は構文エラーで止まる
MySQL 8.0.46 無い(UNION ALL で代替) 使える(内部で LEFT JOIN に書き換え) JOIN・INNER JOIN・CROSS JOIN が構文上同じ。ON 無しは全組み合わせ
SQLite 3.45.1 使える(3.39.0 で追加) 使える(3.39.0 で追加) ON 無しの JOIN・LEFT JOIN は全組み合わせ(20 行)
SQL Server(ドキュメント) 使える 使える USING・NATURAL JOIN は FROM 句の構文に無い
Oracle(ドキュメント) 使える 使える 旧式の (+) 演算子より FROM 句の OUTER JOIN 構文を推奨

MySQL の公式ドキュメントは「MySQL では JOIN・CROSS JOIN・INNER JOIN は構文上同等で置き換えられる。標準 SQL では同等ではなく、INNER JOIN は ON と共に、CROSS JOIN はそれ以外で使う」と説明しています。どの DB でも通じる書き方は「条件で結ぶときは INNER JOIN/LEFT JOIN + ON、全組み合わせは CROSS JOIN」です。 SQLite の RIGHT JOIN・FULL OUTER JOIN は、リリースノートのとおり 3.39.0(2022 年 6 月 25 日)で追加されました。

よくある質問

Q. inner join left join 違いは? A. INNER JOIN は両方の表に相手がいる行だけ、LEFT JOIN は左の表の全行を残し、右に相手がいなければ右の列を NULL にします。この記事のデータでは INNER が 3 行、LEFT が 5 行です。「相手がいない行も結果に出したいか」で選びます。

Q. left outer join inner join 違いは?LEFT OUTER JOIN と LEFT JOIN は別物ですか? A. LEFT OUTER JOIN と LEFT JOIN は同じ結合で、OUTER は省略できます。INNER JOIN との違いは上の質問と同じです。PostgreSQL 16 で両方の書き方が同じ 5 行になることを確かめました。

Q. innerjoin leftjoin とスペース無しで書けますか? A. 書けません。INNERJOIN は MySQL で ERROR 1064、PostgreSQL で syntax error at or near "INNERJOIN" になります。INNER JOIN・LEFT JOIN と 2 語で書きます。

Q. join left join 違いは?JOIN とだけ書いたらどうなりますか? A. JOIN は INNER JOIN と同じです(INNER は省略可)。LEFT JOIN との違いは、相手がいない左の行を残すかどうかです。

Q. sql join 違い・join 種類をまとめると? A. INNER(両方にある行)・LEFT(左の全行)・RIGHT(右の全行)・FULL OUTER(左右の全行)・CROSS(全組み合わせ)の 5 種類に、同じ表を結合する自己結合と、同名列で結合する NATURAL・USING の書き方があります。冒頭の早見表に行数と使う場面をまとめています。

Q. left join 使い方の基本形は? A. SELECT ... FROM 主役の表 a LEFT JOIN 付け足す表 b ON b.a_id = a.id です。右の表(b)に対する条件は WHERE ではなく ON に書きます。件数は COUNT(b.id) で数え、合計は COALESCE(SUM(b.amount), 0) で NULL を 0 にします。

Q. 内部結合 外部結合 違いは? A. 内部結合(INNER JOIN)は条件に合う組だけを返します。外部結合(OUTER JOIN)は、条件に合わなかった行も片方または両方の表について残し、相手の列を NULL にします。LEFT・RIGHT・FULL の 3 つが外部結合です。

Q. LEFT JOIN したのに行が消えました。 A. 右の表の列に対する条件を WHERE に書いていないか確かめます。WHERE は結合の後に評価されるので、右が NULL の行が条件に合わず落ちます。条件を ON に移すか、OR 右.キー IS NULL を足します。後ろに INNER JOIN を続けている場合も、そこで消えます。

Q. JOIN したら合計金額が倍になりました。 A. 1対多の表を 2 つ以上同時に結合して、行が掛け算で増えています。それぞれを先に GROUP BY で集計してから結合するか、相関サブクエリで集計します。DISTINCT では直りません。

Q. WHERE で結合する書き方(FROM a, b WHERE a.id = b.a_id)との違いは? A. 結果は INNER JOIN と同じです(この記事のデータで 3 行)。ただし WHERE を書き忘れると全組み合わせ(20 行)になり、結合条件と絞り込み条件が混ざって読みにくくなります。外部結合も書けないため、JOIN ... ON で書きます。

Q. INNER JOIN と LEFT JOIN はどちらが速いですか? A. 結果の行が違うので比べる対象ではありません。100 万行で測ると 229 ms と 246 ms で差はわずかでした。遅いときは、結合列にインデックスがあるか(EXPLAIN で Seq Scan/Table scan になっていないか)を確かめます。

まとめ

  • INNER JOIN は両方に相手がいる行だけ、LEFT JOIN は左の全行を残して右を NULL で埋める。 4 行 × 5 行の例では INNER 3・LEFT 5・RIGHT 5・FULL 7・CROSS 20 行
  • RIGHT JOIN は左右を入れ替えて LEFT JOIN に書き換える。 MySQL に FULL OUTER JOIN は無く、UNION ALL + IS NULL で代替する
  • LEFT JOIN の右の列の条件は ON に書く。 WHERE に書くと右が NULL の行が消え、INNER JOIN と同じ結果になる
  • 1対多を 2 つ以上同時に結合すると行が掛け算で増え、SUM が二重になる。 先に集計してから結合する。絞り込みだけなら EXISTS
  • NULL のキーは NULL 同士でも一致しない。 相手がいない行は NOT EXISTS か LEFT JOIN + IS NULL で探し、NOT IN は NULL が 1 つで 0 件になる
  • LEFT JOIN の件数は COUNT(右.主キー)。 COUNT(*) は相手がいない行も 1 と数える
  • LEFT JOIN の後ろの INNER JOIN で行が消える。 付け足す表はすべて LEFT JOIN にするか、かっこで先に組にする
  • JOIN が遅いときは結合列のインデックス。 一部の行に絞る処理では 42.7 ms → 0.39 ms、全件結合は Hash Join が選ばれインデックスでは変わらない

参考・出典

確認日はいずれも 2026年10月16日です。

  • PostgreSQL 16 Documentation「7.2. Table Expressions(Joined Tables)」:https://www.postgresql.org/docs/16/queries-table-expressions.html
  • PostgreSQL 16 Documentation「SELECT」:https://www.postgresql.org/docs/16/sql-select.html
  • PostgreSQL 16 Documentation「Planner/Optimizer」:https://www.postgresql.org/docs/16/planner-optimizer.html
  • PostgreSQL 16 Documentation「Using EXPLAIN」:https://www.postgresql.org/docs/16/using-explain.html
  • PostgreSQL 16 Documentation「Constraints(Foreign Keys)」:https://www.postgresql.org/docs/16/ddl-constraints.html
  • MySQL 8.0 Reference Manual「FOREIGN KEY Constraints」:https://dev.mysql.com/doc/refman/8.0/en/create-table-foreign-keys.html
  • MySQL 8.0 Reference Manual「JOIN Clause」:https://dev.mysql.com/doc/refman/8.0/en/join.html
  • MySQL 8.0 Reference Manual「Hash Join Optimization」:https://dev.mysql.com/doc/refman/8.0/en/hash-joins.html
  • MySQL 8.0 Reference Manual「EXPLAIN Statement」:https://dev.mysql.com/doc/refman/8.0/en/explain.html
  • SQLite「SELECT(Joins)」:https://www.sqlite.org/lang_select.html
  • SQLite Release 3.39.0 On 2022-06-25:https://www.sqlite.org/releaselog/3_39_0.html
  • Microsoft Learn「FROM clause plus JOIN, APPLY, PIVOT (Transact-SQL)」:https://learn.microsoft.com/en-us/sql/t-sql/queries/from-transact-sql
  • Oracle Database 23ai SQL Language Reference「Joins」:https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/Joins.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「SQL INNER JOINとLEFT JOINの違い|JOIN種類早見表と落とし穴」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?