- SQL
SQL CROSS JOIN・FULL OUTER JOINの使い方と代替
CROSS JOIN は2つのテーブルの行をすべての組み合わせで並べる結合(直積)、FULL OUTER JOIN は両方のテーブルの行を、相手が見つからない行も含めてすべて残す結合(全外部結合)です。 CROSS JOIN は結合条件を書かず、結果の行数は「左の行数 × 右の行数」になります。FULL OUTER JOIN は LEFT JOIN と RIGHT JOIN を合わせた結果になり、相手がいない側の列には NULL が入ります。
この記事では、同じ2つのテーブルに INNER・LEFT・RIGHT・FULL OUTER・CROSS の5種類をかけて、行数がどう変わるかを最初に見せます。そのうえで、FULL OUTER JOIN が使えない MySQL での代替を2通り示し、よく紹介される UNION 版では正当な重複行が1行にまとまってしまう例を再現します。CROSS JOIN については、日付×商品の全組み合わせ表を作って売上ゼロの日を埋める使い方と、行数が掛け算で増える点を扱います。SQL は PostgreSQL 16.15・MySQL 8.0.46・SQLite 3.45.1 で実行し、出力をそのまま載せています。Oracle と SQL Server は手元で動かしていないため、該当箇所に「未実行」と明記しました。
CROSS JOIN・FULL OUTER JOINの早見表
| 結合 | 残る行 | ON 条件 | 行数の目安(左 m 行・右 n 行) | 主な用途 |
|---|---|---|---|---|
| INNER JOIN | 両方に相手がいる行だけ | 必要 | 一致した組の数 | 対応するデータだけ取り出す |
| LEFT JOIN | 左の全行+一致した右 | 必要 | m 行以上 | 左を主役に、右を付け足す |
| RIGHT JOIN | 右の全行+一致した左 | 必要 | n 行以上 | 右を主役に、左を付け足す |
| FULL OUTER JOIN | 左右の全行 | 必要 | 一致した組+左だけの行+右だけの行 | 2つの表の突き合わせ・差分確認 |
| CROSS JOIN | 全組み合わせ | 書かない | m × n 行 | 日付×商品などの土台表づくり |
FULL OUTER JOIN の対応状況と、MySQL での代替は次のとおりです。
| DB | FULL OUTER JOIN | CROSS JOIN | 今回の確認 |
|---|---|---|---|
| PostgreSQL | 使える | 使える | 16.15 で実行 |
| MySQL | 使えない(代替が必要) | 使える | 8.0.46 で実行。8.4 の公式ドキュメントの構文にも FULL はない |
| SQLite | 3.39.0 以降で使える | 使える | 3.45.1 で実行。3.39.0 のリリースノートで追加を確認 |
| Oracle | 使える | 使える | 未実行(一般に知られた仕様) |
| SQL Server | 使える | 使える | 未実行(一般に知られた仕様) |
| MySQL での代替 | 書き方の骨子 | 重複行の扱い |
|---|---|---|
| 代替1:UNION | LEFT JOIN の結果 UNION RIGHT JOIN の結果 | 同じ内容の行が1行にまとまる(元から同じ行が複数あると減る) |
| 代替2:UNION ALL + IS NULL | LEFT JOIN の結果 UNION ALL(RIGHT JOIN のうち左が NULL の行だけ) | 元の行数をそのまま保つ。こちらがおすすめ |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL CROSS JOIN・FULL OUTER JOINの使い方と代替」
サンプルテーブルを用意する
メンバーとチームの2テーブルを使います。どの DB でもそのまま動く書き方にしています。
CREATE TABLE teams (
id INT PRIMARY KEY,
name VARCHAR(20) NOT NULL
);
CREATE TABLE members (
id INT PRIMARY KEY,
name VARCHAR(20) NOT NULL,
team_id INT
);
INSERT INTO teams VALUES (1, '開発'), (2, '営業'), (3, '総務');
INSERT INTO members VALUES
(1, '佐藤', 1),
(2, '鈴木', 1),
(3, '佐藤', 1),
(4, '高橋', 2),
(5, '田中', NULL);
このデータには、結合の違いが見えるように3つの仕掛けを入れてあります。
| 仕掛け | 該当する行 | 何が見えるか |
|---|---|---|
| チーム未所属のメンバー | 田中(team_id が NULL) | 左だけにある行の扱い |
| メンバーのいないチーム | 総務 | 右だけにある行の扱い |
| 同じチームに同姓のメンバー | 佐藤が2人(id 1 と 3) | UNION で行が減る例 |
同じ2テーブルで5種類の結合結果を比べる
INNER JOINは相手がいる行だけ
SELECT m.id, m.name, t.name AS team
FROM members m
INNER JOIN teams t ON m.team_id = t.id
ORDER BY m.id;
実行結果(PostgreSQL 16.15):
id | name | team
----+------+------
1 | 佐藤 | 開発
2 | 鈴木 | 開発
3 | 佐藤 | 開発
4 | 高橋 | 営業
(4 rows)
田中(未所属)と総務(メンバーなし)は、相手がいないので出てきません。
LEFT JOINは左の全行を残す
SELECT m.id, m.name, t.name AS team
FROM members m
LEFT JOIN teams t ON m.team_id = t.id
ORDER BY m.id;
id | name | team
----+------+------
1 | 佐藤 | 開発
2 | 鈴木 | 開発
3 | 佐藤 | 開発
4 | 高橋 | 営業
5 | 田中 |
(5 rows)
左(members)の田中が残り、team は NULL(psql では空欄で表示)になります。RIGHT JOIN に変えると、今度は右(teams)の総務が残り、田中が出てこなくなります(5 行)。
FULL OUTER JOINは左右の全行を残す
SELECT m.id, m.name, t.name AS team
FROM members m
FULL OUTER JOIN teams t ON m.team_id = t.id
ORDER BY m.id;
id | name | team
----+------+------
1 | 佐藤 | 開発
2 | 鈴木 | 開発
3 | 佐藤 | 開発
4 | 高橋 | 営業
5 | 田中 |
| | 総務
(6 rows)
田中(左だけ)と総務(右だけ)の両方が残りました。 一致した4行に、左だけの1行と右だけの1行が加わって6行です。OUTER は省略でき、FULL JOIN と書いても同じ結果になります。
CROSS JOINは全組み合わせを作る
SELECT m.id, m.name, t.name AS team
FROM members m
CROSS JOIN teams t
ORDER BY m.id, t.id;
id | name | team
----+------+------
1 | 佐藤 | 開発
1 | 佐藤 | 営業
1 | 佐藤 | 総務
2 | 鈴木 | 開発
2 | 鈴木 | 営業
2 | 鈴木 | 総務
(中略:id 3・4 も同じく3行ずつ)
5 | 田中 | 開発
5 | 田中 | 営業
5 | 田中 | 総務
(15 rows)
team_id の値とは関係なく、メンバー5人それぞれに3チームすべてが組み合わされ、5 × 3 = 15 行になりました。
行数をまとめて比べる
SELECT 'INNER' AS kind, COUNT(*) FROM members m INNER JOIN teams t ON m.team_id = t.id
UNION ALL SELECT 'LEFT', COUNT(*) FROM members m LEFT JOIN teams t ON m.team_id = t.id
UNION ALL SELECT 'RIGHT', COUNT(*) FROM members m RIGHT JOIN teams t ON m.team_id = t.id
UNION ALL SELECT 'FULL', COUNT(*) FROM members m FULL JOIN teams t ON m.team_id = t.id
UNION ALL SELECT 'CROSS', COUNT(*) FROM members m CROSS JOIN teams t;
kind | count
-------+-------
INNER | 4
LEFT | 5
RIGHT | 5
FULL | 6
CROSS | 15
(5 rows)
| 結合 | 行数 | 内訳 |
|---|---|---|
| INNER | 4 | 一致した組 4 |
| LEFT | 5 | 一致 4 + 左だけ 1(田中) |
| RIGHT | 5 | 一致 4 + 右だけ 1(総務) |
| FULL | 6 | 一致 4 + 左だけ 1 + 右だけ 1 |
| CROSS | 15 | 5 × 3 |
FULL の行数 = LEFT の行数 + RIGHT の行数 − INNER の行数(5 + 5 − 4 = 6)という関係を覚えておくと、結果の件数を検算できます。
FULL OUTER JOINの使い方
片方にしかない行だけを取り出す
FULL OUTER JOIN の結果から「相手がいない行」だけに絞ると、2つの表のずれを一覧にできます。
SELECT m.id, m.name, t.id AS team_id, t.name AS team
FROM members m
FULL JOIN teams t ON m.team_id = t.id
WHERE m.id IS NULL OR t.id IS NULL;
id | name | team_id | team
----+------+---------+------
5 | 田中 | |
| | 3 | 総務
(2 rows)
判定には NULL にならないはずの列(主キーなど) を使います。t.name IS NULL のように NULL を許す列で判定すると、もともと値が NULL の行と区別がつかなくなります。
2つの表の差分を調べる
FULL OUTER JOIN がいちばん役に立つのは、取り込み前後や旧データと新データなど、同じ形の2つの表を突き合わせる場面です。
CREATE TABLE stock_old (code VARCHAR(10) PRIMARY KEY, qty INT);
CREATE TABLE stock_new (code VARCHAR(10) PRIMARY KEY, qty INT);
INSERT INTO stock_old VALUES ('A01', 10), ('A02', 5), ('A03', 8);
INSERT INTO stock_new VALUES ('A01', 10), ('A02', 7), ('A04', 3);
SELECT COALESCE(o.code, n.code) AS code,
o.qty AS old_qty,
n.qty AS new_qty,
CASE WHEN o.code IS NULL THEN '追加'
WHEN n.code IS NULL THEN '削除'
WHEN o.qty <> n.qty THEN '変更'
ELSE '同じ' END AS diff
FROM stock_old o
FULL OUTER JOIN stock_new n ON o.code = n.code
ORDER BY code;
code | old_qty | new_qty | diff
------+---------+---------+------
A01 | 10 | 10 | 同じ
A02 | 5 | 7 | 変更
A03 | 8 | | 削除
A04 | | 3 | 追加
(4 rows)
ポイントは2つあります。
- 結合キーは COALESCE でまとめる:
o.codeは右だけの行で NULL、n.codeは左だけの行で NULL になります。COALESCE(o.code, n.code)と書くと、どちらにあっても値が入った1つの列になります。 - USING を使うとキーが自動で1列にまとまる:PostgreSQL では
FULL JOIN stock_new n USING (code)と書くと、codeを修飾なしで参照でき、COALESCE を書かなくても同じ結果になることを確認しました。
WHEREで絞ると外部結合の行が消える
FULL OUTER JOIN の結果を WHERE で絞るときは、NULL の行がどうなるかに気を配ります。
-- WHERE で絞る
SELECT m.name, t.name AS team
FROM members m
FULL JOIN teams t ON m.team_id = t.id
WHERE t.name <> '営業'
ORDER BY 1, 2;
name | team
------+------
佐藤 | 開発
佐藤 | 開発
鈴木 | 開発
| 総務
(4 rows)
営業の高橋だけでなく、チーム未所属の田中も消えました。田中の行は t.name が NULL で、NULL <> '営業' は真にならないためです。未所属の行も残したいなら、WHERE t.name <> '営業' OR t.name IS NULL と NULL の場合を明示します。
なお、条件を ON に移すと意味が変わります。ON m.team_id = t.id AND t.name <> '営業' と書いて実行したところ、高橋は「相手なし」の行として残り、営業も「メンバーなし」の行として残って、全部で 7 行になりました。ON は「どの行同士を組にするか」、WHERE は「結合後にどの行を残すか」 と役割が違うので、絞り込みは WHERE に書くのが基本です。
PostgreSQLでは結合条件に制約がある
PostgreSQL の FULL JOIN は、等号で結べる条件を前提にしています。不等号や OR を使った条件では、次のメッセージで止まりました。
SELECT * FROM members m FULL JOIN teams t ON m.team_id < t.id;
ERROR: FULL JOIN is only supported with merge-joinable or hash-joinable join conditions
ON m.team_id = t.id OR m.id = t.id のように OR でつないだ条件でも同じメッセージになります。この場合は、後述の「LEFT JOIN と RIGHT JOIN を UNION ALL でつなぐ」形に書き換えると同じ結果が得られます。
MySQLでFULL OUTER JOINを代替する2通りの方法
MySQLでFULL OUTER JOINを書くとどうなるか
MySQL 8.0.46 で実行すると、構文エラーになります。
SELECT m.id, m.name, t.name AS team
FROM members m
FULL OUTER JOIN teams t ON m.team_id = t.id;
ERROR 1064 (42000) at line 1: 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 teams t ON m.team_id = t.id' at line 1
テーブル別名を付けずに FULL JOIN と書くと、少しわかりにくいメッセージに変わります。
SELECT * FROM members FULL JOIN teams ON members.team_id = teams.id;
ERROR 1054 (42S22) at line 1: Unknown column 'members.team_id' in 'on clause'
列はあるのに「Unknown column」と言われるのは、MySQL が FULL を members の別名として読んでいるためです。つまり members AS FULL JOIN teams と解釈され、ただの JOIN(内部結合)として処理されます。実際に ON FULL.team_id = teams.id と書き換えるとエラーにならず、INNER JOIN と同じ4行が返りました。エラーが出ないまま内部結合の結果になる場合があるので、MySQL では FULL JOIN と書かず、次の代替を使います。
代替1:LEFT JOINとRIGHT JOINをUNIONでつなぐ
もっともよく紹介されている形です。
SELECT m.id, m.name, t.name AS team
FROM members m
LEFT JOIN teams t ON m.team_id = t.id
UNION
SELECT m.id, m.name, t.name
FROM members m
RIGHT JOIN teams t ON m.team_id = t.id;
実行結果(MySQL 8.0.46):
+------+--------+--------+
| id | name | team |
+------+--------+--------+
| 1 | 佐藤 | 開発 |
| 2 | 鈴木 | 開発 |
| 3 | 佐藤 | 開発 |
| 4 | 高橋 | 営業 |
| 5 | 田中 | NULL |
| NULL | NULL | 総務 |
+------+--------+--------+
LEFT JOIN と RIGHT JOIN の両方に出てくる「一致した4行」を、UNION の重複排除でまとめています。主キーの id を選んでいるこの例では、PostgreSQL の FULL OUTER JOIN と同じ6行になりました。
UNIONだと正当な重複行が消える例
ところが、選ぶ列を変えると結果が変わります。主キーを外して、名前とチーム名だけを取ってみます。
SELECT m.name, t.name AS team
FROM members m
LEFT JOIN teams t ON m.team_id = t.id
UNION
SELECT m.name, t.name
FROM members m
RIGHT JOIN teams t ON m.team_id = t.id;
+--------+--------+
| name | team |
+--------+--------+
| 佐藤 | 開発 |
| 鈴木 | 開発 |
| 高橋 | 営業 |
| 田中 | NULL |
| NULL | 総務 |
+--------+--------+
5行しかありません。 開発チームには佐藤が2人いるのに、1行にまとまっています。PostgreSQL で同じ列を FULL OUTER JOIN した結果と比べると違いがはっきりします。
name | team
------+------
佐藤 | 開発
佐藤 | 開発
田中 |
鈴木 | 開発
高橋 | 営業
| 総務
(6 rows)
UNION は「LEFT と RIGHT の両方に出てきた同じ行」だけでなく、もともと別の行だった同じ内容の行までまとめてしまいます。人数を数える、数量を合計するといった集計にこの結果を使うと、値が実際より小さくなります。主キーを SELECT に含めていれば起きませんが、列を選び直したときに気づきにくいのが難しいところです。
代替2:UNION ALLとIS NULLでつなぐ
重複排除に頼らず、RIGHT JOIN 側からは「左に相手がいない行」だけを取る形にします。
SELECT m.name, t.name AS team
FROM members m
LEFT JOIN teams t ON m.team_id = t.id
UNION ALL
SELECT m.name, t.name
FROM members m
RIGHT JOIN teams t ON m.team_id = t.id
WHERE m.id IS NULL;
実行結果(MySQL 8.0.46):
+--------+--------+
| name | team |
+--------+--------+
| 佐藤 | 開発 |
| 鈴木 | 開発 |
| 佐藤 | 開発 |
| 高橋 | 営業 |
| 田中 | NULL |
| NULL | 総務 |
+--------+--------+
佐藤が2行とも残り、FULL OUTER JOIN と同じ6行になりました。考え方は次のとおりです。
| 部分 | 取り出す行 | この例での行数 |
|---|---|---|
| 前半:LEFT JOIN | 一致した行 + 左だけの行 | 4 + 1 = 5 |
後半:RIGHT JOIN + WHERE m.id IS NULL |
右だけの行 | 1 |
| UNION ALL | 重なりがないので、そのまま足す | 6 |
前半と後半に同じ行が入らないので、重複を取り除く必要がありません。UNION ALL は並べ替えや比較による重複排除の処理をしないぶん、行数が多いときの負荷も軽くなります。WHERE m.id IS NULL の判定には、左テーブルの NULL にならない列(主キー) を使ってください。
2つの代替の比較
| 観点 | 代替1:UNION | 代替2:UNION ALL + IS NULL |
|---|---|---|
| 書きやすさ | 短い | WHERE が1行増える |
| 元から同じ内容の行が複数あるとき | 1行にまとまる(行が減る) | そのまま残る |
| 主キーを SELECT に含めているとき | FULL OUTER JOIN と同じ結果 | FULL OUTER JOIN と同じ結果 |
| 重複排除の処理 | 行う | 行わない |
| おすすめ度 | 主キーを必ず選ぶ場合に限って使える | 基本はこちら |
差分確認の例も、MySQL では代替2の形で書けます。
SELECT o.code, o.qty AS old_qty, n.qty AS new_qty
FROM stock_old o
LEFT JOIN stock_new n ON o.code = n.code
UNION ALL
SELECT n.code, o.qty, n.qty
FROM stock_old o
RIGHT JOIN stock_new n ON o.code = n.code
WHERE o.code IS NULL
ORDER BY code;
+------+---------+---------+
| code | old_qty | new_qty |
+------+---------+---------+
| A01 | 10 | 10 |
| A02 | 5 | 7 |
| A03 | 8 | NULL |
| A04 | NULL | 3 |
+------+---------+---------+
後半の SELECT では、キーを n.code から取っている点に注目してください。右だけの行では o.code が NULL だからです。
CROSS JOINの使い方
CROSS JOIN は「条件なしで全部を組み合わせる」結合です。そのまま結果として使うより、集計の土台になる表を作る用途で力を発揮します。
日付×商品の全組み合わせ表を作って売上ゼロを埋める
売上テーブルを日付と商品で集計すると、売れなかった日の商品は行そのものが出てきません。
CREATE TABLE products (id INT PRIMARY KEY, name VARCHAR(20) NOT NULL);
CREATE TABLE sales (sold_on DATE NOT NULL, product_id INT NOT NULL, qty INT NOT NULL);
INSERT INTO products VALUES (1, 'ノート'), (2, 'ペン'), (3, '付箋');
INSERT INTO sales VALUES
('2026-10-01', 1, 5), ('2026-10-01', 2, 3),
('2026-10-02', 1, 2),
('2026-10-03', 3, 4), ('2026-10-03', 1, 1), ('2026-10-03', 1, 2);
SELECT s.sold_on, p.name, SUM(s.qty) AS qty
FROM sales s
JOIN products p ON p.id = s.product_id
GROUP BY s.sold_on, p.name
ORDER BY s.sold_on, p.name;
sold_on | name | qty
------------+--------+-----
2026-10-01 | ノート | 5
2026-10-01 | ペン | 3
2026-10-02 | ノート | 2
2026-10-03 | ノート | 3
2026-10-03 | 付箋 | 4
(5 rows)
3日 × 3商品 = 9 通りのうち、売れた5通りしか出ていません。グラフや表にするときは「0」の行も欲しくなります。そこで、日付の一覧と商品の一覧を CROSS JOIN して9行の土台を作り、そこへ売上を LEFT JOIN します。
PostgreSQL 16 での書き方:
SELECT d.day::date AS sold_on, p.name, COALESCE(SUM(s.qty), 0) AS qty
FROM generate_series(DATE '2026-10-01', DATE '2026-10-03', INTERVAL '1 day') AS d(day)
CROSS JOIN products p
LEFT JOIN sales s ON s.sold_on = d.day::date AND s.product_id = p.id
GROUP BY d.day, p.id, p.name
ORDER BY d.day, p.id;
sold_on | name | qty
------------+--------+-----
2026-10-01 | ノート | 5
2026-10-01 | ペン | 3
2026-10-01 | 付箋 | 0
2026-10-02 | ノート | 2
2026-10-02 | ペン | 0
2026-10-02 | 付箋 | 0
2026-10-03 | ノート | 3
2026-10-03 | ペン | 0
2026-10-03 | 付箋 | 4
(9 rows)
MySQL 8.0 では generate_series がないので、再帰 CTE(WITH RECURSIVE)で日付の一覧を作ります。
WITH RECURSIVE days AS (
SELECT DATE '2026-10-01' AS day
UNION ALL
SELECT day + INTERVAL 1 DAY FROM days WHERE day < DATE '2026-10-03'
)
SELECT d.day AS sold_on, p.name, COALESCE(SUM(s.qty), 0) AS qty
FROM days d
CROSS JOIN products p
LEFT JOIN sales s ON s.sold_on = d.day AND s.product_id = p.id
GROUP BY d.day, p.id, p.name
ORDER BY d.day, p.id;
MySQL 8.0.46 でも、PostgreSQL と同じ9行(売れていない組み合わせは 0)が返ることを確認しました。処理の流れは次の3段階です。
| 段階 | やること | 行数 |
|---|---|---|
| 1 | 日付の一覧(3行)と商品の一覧(3行)を CROSS JOIN | 9 |
| 2 | 売上を LEFT JOIN(相手がない組は NULL) | 9 以上(同じ日・同じ商品の売上が複数あれば増える) |
| 3 | GROUP BY で日付×商品にまとめ、COALESCE で NULL を 0 に | 9 |
サイズ×色などの組み合わせを作る
選択肢の全パターンを作る用途にも向いています。
SELECT s.size, c.color, s.size || '-' || c.color AS sku
FROM (VALUES ('S'), ('M'), ('L')) AS s(size)
CROSS JOIN (VALUES ('白'), ('黒')) AS c(color);
size | color | sku
------+-------+------
S | 白 | S-白
S | 黒 | S-黒
M | 白 | M-白
M | 黒 | M-黒
L | 白 | L-白
L | 黒 | L-黒
(6 rows)
これは PostgreSQL 16 での実行結果です。文字列の連結に使った || は、MySQL の標準設定では連結ではなく論理和として扱われるため、MySQL では CONCAT(s.size, '-', c.color) と書きます。MySQL 8.0.46 では、VALUES の部分を SELECT 'S' AS size UNION ALL SELECT 'M' ... の形に置き換えたうえで、同じ6通りが返ることを確認しました。
1行だけの集計結果を全行に付ける
CROSS JOIN の相手が1行だけなら、行数は増えません。全体の合計を各行に付けて、構成比を出す例です。
SELECT p.name, s.qty, t.total, ROUND(100.0 * s.qty / t.total, 1) AS pct
FROM (SELECT product_id, SUM(qty) AS qty FROM sales GROUP BY product_id) s
JOIN products p ON p.id = s.product_id
CROSS JOIN (SELECT SUM(qty) AS total FROM sales) t
ORDER BY p.id;
name | qty | total | pct
--------+-----+-------+------
ノート | 10 | 17 | 58.8
ペン | 3 | 17 | 17.6
付箋 | 4 | 17 | 23.5
(3 rows)
合計を求めるサブクエリは必ず1行を返すので、3 × 1 = 3 行のままです。
行数は掛け算で増える
CROSS JOIN で気をつけたいのは行数です。結果は「左の行数 × 右の行数」になるので、元の表が少し大きくなるだけで結果は一気に増えます。
SELECT COUNT(*) AS rows_
FROM generate_series(1, 365) a
CROSS JOIN generate_series(1, 1000) b;
rows_
--------
365000
(1 row)
365 日 × 1,000 商品で 36 万 5 千行です。目安を表にしておきます(掛け算による計算値)。
| 左の行数 | 右の行数 | 結果の行数 |
|---|---|---|
| 5 | 3 | 15 |
| 365 | 1,000 | 365,000 |
| 10,000 | 10,000 | 100,000,000 |
| 100,000 | 100,000 | 10,000,000,000 |
CROSS JOIN を書くときは、次の点を確かめると落ち着いて使えます。
- 両方の行数を先に COUNT で確かめる。掛け算の結果が想定の範囲かを見る
- 一覧側(日付・商品など)は WHERE やサブクエリで必要な範囲に絞ってから 組み合わせる
- 意図せず全組み合わせになっていないか、結果の件数を検算する
書き方の違いで意図せず全組み合わせになる場合
結合条件を書き忘れたときの挙動は DB によって違います。同じ SQL を3つの DB で実行した結果です。
| 書き方 | PostgreSQL 16.15 | MySQL 8.0.46 | SQLite 3.45.1 |
|---|---|---|---|
FROM members m CROSS JOIN teams t |
15 行 | 15 行 | 15 行 |
FROM members m, teams t(カンマ区切り) |
15 行 | 15 行 | 15 行 |
FROM members m INNER JOIN teams t(ON なし) |
構文エラー | 15 行 | 15 行 |
FROM members m JOIN teams t(ON なし) |
構文エラー | 15 行 | 15 行 |
FROM members m CROSS JOIN teams t ON m.team_id = t.id |
構文エラー | 4 行 | 4 行 |
FROM members m LEFT JOIN teams t(ON なし) |
構文エラー | 構文エラー | 15 行 |
- MySQL では JOIN・INNER JOIN・CROSS JOIN が構文上は同じものとして扱われます。公式ドキュメント(8.4 の JOIN 構文のページ)にも「MySQL では JOIN、CROSS JOIN、INNER JOIN は構文上同等で、標準 SQL では同等ではない。INNER JOIN は ON と一緒に、CROSS JOIN はそれ以外で使う」という趣旨の説明があります。そのため、INNER JOIN の ON を書き忘れてもエラーにならず、全組み合わせが返ります。
- SQLite 3.45.1 では、ON のない INNER JOIN・LEFT JOIN も全組み合わせの 15 行を返しました。
- PostgreSQL では INNER JOIN に ON がないと構文エラー、CROSS JOIN に ON を付けても構文エラーになるので、書き間違いに気づきやすくなっています。
- どの DB でも通じるように、全組み合わせが欲しいときは CROSS JOIN と明記し、条件で結ぶときは INNER JOIN + ON と書き分けると、読む人にも意図が伝わります。
CROSS JOIN に WHERE で条件を付けると、内部結合と同じ結果になります。PostgreSQL で FROM members m CROSS JOIN teams t WHERE m.team_id = t.id を実行すると、INNER JOIN と同じ4行が返りました。結果は同じですが、結合条件は ON に書いたほうが「どれが結合条件で、どれが絞り込み条件か」を読み分けやすくなります。
どの結合を選ぶかの判断表
| 知りたいこと | 選ぶ結合 |
|---|---|
| 両方にあるデータだけ | INNER JOIN |
| 左の表を全部出し、右は「あれば付ける」 | LEFT JOIN |
| 左右どちらかにしかないデータも含めて全部 | FULL OUTER JOIN(MySQL は UNION ALL + IS NULL) |
| 2つの表で食い違う行だけ | FULL OUTER JOIN + WHERE 左.キー IS NULL OR 右.キー IS NULL |
| 左にしかないデータだけ | LEFT JOIN + WHERE 右.キー IS NULL |
| 全組み合わせの土台(日付×商品など) | CROSS JOIN |
| 全体の合計など1行の値を各行に付ける | 1行のサブクエリと CROSS JOIN |
つまずきやすい点のチェックリスト
| 症状 | 原因 | 直し方 |
|---|---|---|
| MySQL で FULL OUTER JOIN が構文エラー(1064) | MySQL は FULL OUTER JOIN に対応していない | UNION ALL + IS NULL の形に書き換える |
| MySQL で「Unknown column ... in 'on clause'」 | FULL がテーブル別名として解釈されている |
FULL JOIN を使わず代替の形にする |
| 代替の結果が FULL OUTER JOIN より少ない | UNION が同じ内容の行をまとめている | UNION ALL + IS NULL にする、または主キーを SELECT に含める |
| FULL OUTER JOIN のキー列が NULL になる | 片側の列だけを選んでいる | COALESCE(左.キー, 右.キー) か USING を使う |
| WHERE を付けたら相手なしの行が消えた | NULL との比較が真にならない | OR 列 IS NULL を加える |
| 結果の行数が想定よりずっと多い | ON の書き忘れや CROSS JOIN で全組み合わせになっている | 結合条件を確かめ、両方の行数を COUNT で検算する |
| PostgreSQL で「FULL JOIN is only supported with ...」 | 等号以外の結合条件を使っている | 等号の条件にするか、LEFT / RIGHT + UNION ALL に書き換える |
よくある質問
Q. FULL OUTER JOIN と FULL JOIN は違いますか?
A. 同じです。OUTER は省略できます。LEFT OUTER JOIN と LEFT JOIN、RIGHT OUTER JOIN と RIGHT JOIN も同じ関係です。PostgreSQL 16 と SQLite 3.45.1 で、どちらの書き方でも同じ6行が返ることを確認しました。
Q. MySQL でも将来 FULL OUTER JOIN が使えるようになりますか? A. 今回確認した MySQL 8.4 の公式ドキュメントの JOIN 構文には FULL がありませんでした。手元の 8.0.46 でも構文エラーになります。現時点では、UNION ALL + IS NULL の代替で書くのが確実です。
Q. CROSS JOIN とカンマ区切り(FROM a, b)は同じですか? A. 条件を付けなければ、どちらも全組み合わせを返します(PostgreSQL・MySQL・SQLite のいずれでも 15 行になることを確認)。ただし MySQL の公式ドキュメントには、カンマは JOIN より優先順位が低いという説明があり、カンマと JOIN を混ぜると結合の順番が変わることがあります。読みやすさの面でも、CROSS JOIN と明記する書き方がおすすめです。
Q. CROSS JOIN は処理が重いと聞きました。使わないほうがよいですか? A. 行数が掛け算で増える性質を理解して使えば、便利な道具です。日付×商品のように「組み合わせの数が見積もれる小さな一覧同士」を組み合わせる用途に向いています。数万行同士の表をそのまま組み合わせると結果が億単位になるので、先に必要な範囲へ絞ってから使います。
まとめ
- CROSS JOIN は全組み合わせ(直積)。ON を書かず、行数は左 × 右になる
- FULL OUTER JOIN は左右の全行を残す。行数は「LEFT + RIGHT − INNER」で検算できる
- MySQL は FULL OUTER JOIN に未対応。PostgreSQL・Oracle・SQL Server は対応、SQLite は 3.39.0 以降で対応
- MySQL での代替は「UNION ALL + IS NULL」が基本。UNION 版は、元から同じ内容の行が複数あると1行にまとまり、行数が減る
- FULL OUTER JOIN のキーは COALESCE か USING でまとめる。差分確認は「どちらかのキーが NULL」で判定する
- CROSS JOIN は日付×商品などの土台表づくりに使う。LEFT JOIN と COALESCE を組み合わせると、売上ゼロの行を埋められる
- CROSS JOIN の前に両方の行数を確かめる。MySQL では INNER JOIN の ON を書き忘れても全組み合わせになる
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「SQL CROSS JOIN・FULL OUTER JOINの使い方と代替」
株式会社RJCでは、SQLの結合のような基礎を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。