SQL の JOIN の種類と違い - INNER / LEFT / FULL / CROSS の結果を例で理解する

SQL の JOIN の種類と違い - INNER / LEFT / FULL / CROSS の結果を例で理解する

作成日:
読了:約36分
更新日:

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

INNER JOIN と LEFT JOIN の違いは説明できるのに、いざクエリを書くと「行が消えた」「行が増えた」「合計金額が合わない」で手が止まる。JOIN でつまずく場面の多くは、構文ではなく結果の行数がどう決まるかの理解にあります。

この記事では、小さなサンプルテーブルを最初に1回だけ定義し、すべての JOIN をそのデータに対して実行して結果を並べます。種類ごとの違いに加えて、ON と WHERE の違い、NULL の扱い、1対多で行が増える問題、EXISTS を使った絞り込みまで、実務で引っかかりやすい箇所を順に確認します。

NOTE

記事中の結果表は、LATERAL の節を除き、すべて SQLite 3.54.0 で実際に実行した出力です。PostgreSQL と MySQL に固有の構文や対応バージョンは、各公式ドキュメントとリリースノートで確認した内容を載せています(手元での実行はしていません)。

JOIN とは - 分けたテーブルをつなぎ直す操作

リレーショナルデータベースでは、同じ事実を何か所にも持たないようにテーブルを分けて設計します(データベース正規化入門)。ユーザーの名前は users に、注文は orders に置き、注文側にはユーザーの ID だけを持たせる、という形です。

分けたままでは「誰がいくら注文したか」を一覧にできません。そこで、2つのテーブルの行を条件に従って横につなぐのが JOIN です。JOIN の種類の違いは、突き詰めると次の1点に集約されます。

  • 相手が見つからなかった行を、結果に残すか捨てるか

これに「相手が複数見つかった行は、その数だけ複製される」という性質を加えれば、JOIN の結果の行数はすべて説明できます。

サンプルデータ

以降の例はすべて次のデータを使います。

サンプルテーブルの定義とデータ
CREATE TABLE users (
  id          INTEGER PRIMARY KEY,
  name        TEXT NOT NULL,
  referrer_id INTEGER REFERENCES users(id)   -- 紹介者(いなければ NULL)
);
 
CREATE TABLE orders (
  id      INTEGER PRIMARY KEY,
  user_id INTEGER REFERENCES users(id),      -- ゲスト購入は NULL
  amount  INTEGER NOT NULL
);
 
INSERT INTO users VALUES
  (1, 'Alice', NULL), (2, 'Bob', 1), (3, 'Carol', 1), (4, 'Dave', 2);
 
INSERT INTO orders VALUES
  (101, 1, 3000), (102, 1, 1500), (103, 2, 2000), (104, NULL, 800);

users テーブル:

idnamereferrer_id
1AliceNULL
2Bob1
3Carol1
4Dave2

orders テーブル:

iduser_idamount
10113000
10211500
10322000
104NULL800

ポイントは、一致しない行を両側に入れてあることです。

  • Alice は注文が2件ある(1対多)
  • Carol と Dave は注文が1件もない(users 側にだけある行)
  • 注文 104 は user_id が NULL のゲスト購入(orders 側にだけある行)

INNER JOIN - 両方に相手がいる行だけ

INNER JOIN は、結合条件を満たす組み合わせだけを返します。INNER は省略でき、単に JOIN と書いても同じです。

INNER JOIN
SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount
FROM users u
INNER JOIN orders o ON o.user_id = u.id
ORDER BY u.id, o.id;
user_idnameorder_idamount
1Alice1013000
1Alice1021500
2Bob1032000

注文のない Carol と Dave、ユーザーに紐づかない注文 104 は結果に出てきません。一方で Alice は注文が2件あるので2行になっています。users は4行、orders は4行ですが、結果は3行です。JOIN の結果の行数は、元のテーブルのどちらの行数とも一致するとは限りません。

LEFT JOIN - 左のテーブルの行は必ず残す

LEFT JOIN(LEFT OUTER JOIN と同じ。OUTER は省略可)は、まず INNER JOIN を行い、そのうえで左のテーブルで相手が見つからなかった行を、右側の列を NULL で埋めて追加します。PostgreSQL の公式ドキュメントも、結果には左テーブルの各行について少なくとも1行が必ず含まれる、と説明しています。

LEFT JOIN
SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
ORDER BY u.id, o.id;
user_idnameorder_idamount
1Alice1013000
1Alice1021500
2Bob1032000
3CarolNULLNULL
4DaveNULLNULL

Carol と Dave が残り、注文側の列が NULL になりました。「左」とは FROM の直後に書いたテーブル、つまり LEFT JOIN というキーワードの左側にあるテーブルのことです。

「注文がなくてもユーザーは全員表示したい」という一覧画面では LEFT JOIN を使います。逆に、相手が必ず存在するはずの結合(注文明細に対する商品など)で LEFT JOIN を使うと、データの不整合が NULL の行として静かに紛れ込むので、INNER JOIN のほうが意図に合います。

RIGHT JOIN - 右のテーブルの行を必ず残す

RIGHT JOIN は LEFT JOIN の左右を入れ替えたものです。右のテーブルの行がすべて残ります。

RIGHT JOIN
SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount
FROM users u
RIGHT JOIN orders o ON o.user_id = u.id
ORDER BY o.id;
user_idnameorder_idamount
1Alice1013000
1Alice1021500
2Bob1032000
NULLNULL104800

今度はゲスト購入の注文 104 が残り、ユーザー側の列が NULL になりました。

このクエリは FROM orders o LEFT JOIN users u ON o.user_id = u.id と書いても同じ結果になります。RIGHT JOIN でしか書けないクエリは基本的にないため、「残したいテーブルを先に書いて LEFT JOIN でつなぐ」に統一しておくと読みやすくなります。MySQL のリファレンスマニュアルも、データベース間の移植性のために RIGHT JOIN ではなく LEFT JOIN を使うことを推奨しています。

FULL OUTER JOIN - 両側の行をすべて残す

FULL OUTER JOIN(FULL JOIN)は、LEFT JOIN と RIGHT JOIN を合わせたものです。どちらの側でも、相手が見つからなかった行が NULL 埋めで残ります。

FULL OUTER JOIN
SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount
FROM users u
FULL OUTER JOIN orders o ON o.user_id = u.id;
user_idnameorder_idamount
1Alice1013000
1Alice1021500
2Bob1032000
3CarolNULLNULL
4DaveNULLNULL
NULLNULL104800

2つのデータソースを突き合わせて「片方にしかないもの」を洗い出す、といった照合作業で使います。

注意点として、MySQL には FULL OUTER JOIN がありません。MySQL 8.4 のリファレンスマニュアルの JOIN 構文に載っているのは INNER、CROSS、LEFT、RIGHT、NATURAL、STRAIGHT_JOIN で、FULL は含まれていません。MySQL では LEFT JOIN と「右側にしかない行」を UNION ALL でつなげて代替します。

FULL OUTER JOIN の代替(MySQL など)
SELECT u.id AS user_id, u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
 
UNION ALL
 
SELECT u.id, u.name, o.id, o.amount
FROM users u
RIGHT JOIN orders o ON o.user_id = u.id
WHERE u.id IS NULL;

このクエリを SQLite で実行すると、行の並びを除いて上の FULL OUTER JOIN と同じ6行が返りました。LEFT JOIN と RIGHT JOIN を単純に UNION でつなぐ書き方もよく紹介されますが、UNION は重複行を1行にまとめるため、元のデータにまったく同じ内容の行が複数あると行数が変わってしまいます。後半を「左に相手がいない行だけ」に絞って UNION ALL でつなぐほうが、FULL OUTER JOIN の定義に忠実です。

CROSS JOIN - すべての組み合わせ

CROSS JOIN は結合条件を持たず、左の全行と右の全行のすべての組み合わせ(直積)を返します。行数は「左の行数 × 右の行数」です。

CROSS JOIN
SELECT u.name, o.id AS order_id
FROM users u
CROSS JOIN orders o
WHERE u.id IN (1, 2)
ORDER BY u.id, o.id;

全体では 4 × 4 = 16行になるので、ここでは Alice と Bob の分だけに絞って表示しています。

nameorder_id
Alice101
Alice102
Alice103
Alice104
Bob101
Bob102
Bob103
Bob104

実務での用途は、「全店舗 × 全日付」のような集計の土台を作る場面です。売上がゼロの日も行として出したいとき、CROSS JOIN で枠を作ってから実績を LEFT JOIN します。

意図せず CROSS JOIN と同じ状態になる事故にも注意が必要です。結合条件を書き忘れた場合や、FROM users, orders のようにカンマで並べる古い書き方で WHERE の条件を落とした場合、直積がそのまま返ります。1万行どうしなら1億行です。なお、MySQL では JOIN、CROSS JOIN、INNER JOIN が構文上同じものとして扱われ、相互に置き換えられます。標準 SQL ではこれらは等価ではないので、他のデータベースでも動かすクエリでは「条件ありは INNER JOIN、条件なしは CROSS JOIN」と書き分けておくのが安全です。

自己結合(SELF JOIN) - 同じテーブルをつなぐ

自己結合は専用の構文ではなく、同じテーブルに別名を付けて2回登場させる書き方のことです。users.referrer_id は同じ users の id を指しているので、紹介者の名前を出すには users どうしを結合します。

自己結合(紹介者の名前を出す)
SELECT u.name, r.name AS referrer
FROM users u
LEFT JOIN users r ON r.id = u.referrer_id
ORDER BY u.id;
namereferrer
AliceNULL
BobAlice
CarolAlice
DaveBob

u を「本人」、r を「紹介者」という別のテーブルだと考えると読みやすくなります。ここで LEFT JOIN を INNER JOIN に変えると、紹介者のいない Alice が結果から消えて3行になります。社員と上司、カテゴリと親カテゴリのような階層構造で頻繁に使う形です。

ON・USING・NATURAL JOIN の違い

結合条件の書き方は3通りあります。

書き方条件の指定結合列の出力
ON任意の条件式を書く両方のテーブルの列がそのまま出る
USING (列名)両方のテーブルにある同名の列を等値で結合結合列が1つにまとめられる
NATURAL JOIN同名の列すべてを自動的に等値で結合結合列が1つにまとめられる

USING は両方のテーブルで列名が同じときだけ使えます。サンプルの users.id と orders.user_id は名前が違うので使えません。そこで、同じ user_id 列を持つ問い合わせテーブル tickets を1つ追加します(この節と、後の「行が増える問題」の節で使います)。

tickets テーブル
CREATE TABLE tickets (
  id      INTEGER PRIMARY KEY,
  user_id INTEGER REFERENCES users(id),
  subject TEXT
);
 
INSERT INTO tickets VALUES
  (201, 1, '配送について'), (202, 1, '返品について'), (203, 3, 'ログインできない');
USING
SELECT user_id, o.id AS order_id, t.id AS ticket_id
FROM orders o
JOIN tickets t USING (user_id)
ORDER BY o.id, t.id;
user_idorder_idticket_id
1101201
1101202
1102201
1102202

USING (user_id) は ON o.user_id = t.user_id と同じ条件です。違いは、結合列が1つにまとめられるため、user_id をテーブル名なしで参照できる点です。

NATURAL JOIN は、この USING の列名を省略して「同じ名前の列すべて」で結合する書き方です。短く書けますが、実務では避けるのが無難です。サンプルデータで試すと理由がよくわかります。

NATURAL JOIN(意図と違う結果になる)
SELECT * FROM users NATURAL JOIN orders;

このクエリの結果は0行でした。users と orders に共通する列名は id だけなので、users.id = orders.id(ユーザー ID と注文 ID の比較)で結合されてしまうためです。エラーにはならず、ただ間違った結果が返ります。

さらに問題なのは、いま正しく動いている NATURAL JOIN が、あとから壊れる可能性があることです。両方のテーブルに created_at や updated_at のような同名の列を追加した瞬間に、その列も結合条件に加わります。PostgreSQL の公式ドキュメントも、USING は列挙した列だけが対象なのでスキーマ変更に対して比較的安全だが、NATURAL はスキーマ変更で同名の列が現れるとその列も結合に使われるためかなり危険だ、と注意しています。

ON と WHERE の違い - LEFT JOIN が INNER JOIN になる落とし穴

INNER JOIN では、絞り込み条件を ON に書いても WHERE に書いても結果は同じです。ところが外部結合では結果が変わります。PostgreSQL の公式ドキュメントは、ON 句の条件は結合の前に、WHERE 句の条件は結合の後に処理されると説明し、内部結合では問題にならないが外部結合では大きな違いになる、と書いています。

「全ユーザーと、2000円以上の注文」を出したいとします。まず条件を ON に書いた場合です。

条件を ON に書く
SELECT u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.amount >= 2000
ORDER BY u.id, o.id;
nameorder_idamount
Alice1013000
Bob1032000
CarolNULLNULL
DaveNULLNULL

「2000円以上の注文」だけが結合の相手になり、相手がいないユーザーは NULL 埋めで残ります。次に、同じ条件を WHERE に書いた場合です。

条件を WHERE に書く
SELECT u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.amount >= 2000
ORDER BY u.id, o.id;
nameorder_idamount
Alice1013000
Bob1032000

Carol と Dave が消えました。LEFT JOIN でいったん NULL 埋めの行が作られたあと、WHERE o.amount >= 2000 がその行に適用され、NULL >= 2000 は真にならないので落とされたためです。LEFT JOIN の右側のテーブルの列に対する条件を WHERE に書くと、結果は INNER JOIN と同じになります。

整理すると、次のように使い分けます。

  • 「結合の相手をどれにするか」の条件は ON に書く
  • 「最終的にどの行を残すか」の条件は WHERE に書く
  • LEFT JOIN で右側のテーブルの条件を WHERE に書いてよいのは、o.id IS NULL のように NULL 埋めの行を意図して扱う場合だけ

もう1つ、逆方向の勘違いもあります。左側のテーブルの条件を ON に書いても、左の行は減りません。

左テーブルの条件を ON に書いても行は減らない
SELECT u.name, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND u.name = 'Alice'
ORDER BY u.id, o.id;
nameorder_id
Alice101
Alice102
BobNULL
CarolNULL
DaveNULL

Alice だけに絞るつもりが、全員が残っています。ON は「相手が見つかるかどうか」を決めるだけで、左の行を捨てる力はありません。Bob には注文 103 があるのに NULL になっている点にも注目してください。左の行を絞りたいなら WHERE u.name = 'Alice' と書きます。

NULL は NULL と一致しない

サンプルの注文 104 は user_id が NULL でした。INNER JOIN でこの行が出てこないのは、users に対応する行がないからだけではありません。SQL ではNULL との比較は真にも偽にもならず NULL(不明)になるため、仮に相手側の列にも NULL があったとしても一致しません。

NULL の比較
SELECT NULL = NULL                     AS eq,
       NULL IS NOT DISTINCT FROM NULL  AS not_distinct,
       1 IS NOT DISTINCT FROM NULL     AS one_vs_null;
eqnot_distinctone_vs_null
NULL10

NULL = NULL の結果は NULL で、結合条件としては不成立です。NULL どうしを「同じ」とみなして結合したい場合は、NULL を比較可能な値として扱う演算子を使います。

データベースNULL どうしを等しいとみなす比較
PostgreSQLa IS NOT DISTINCT FROM b
MySQLa <=> b(公式マニュアルに、標準 SQL の IS NOT DISTINCT FROM と等価と記載)
SQLitea IS b。3.39.0 以降は a IS NOT DISTINCT FROM b も使える

ただし、結合キーに NULL が入る設計自体を見直せないかを先に考える価値があります。「未設定」を NULL で表している列どうしを結合すると、無関係な行が「どちらも未設定だから」という理由でつながってしまうからです。

1対多の結合で行が増える問題

JOIN で最も実害が出やすいのが、行が増えることによる集計のずれです。INNER JOIN の節で見たとおり、相手が複数いる行はその数だけ複製されます。

件数がずれる

「注文したことのあるユーザーは何人か」を数えるつもりで、次のように書いたとします。

件数がずれる例
SELECT COUNT(*) AS cnt, COUNT(DISTINCT u.id) AS users
FROM users u
JOIN orders o ON o.user_id = u.id;
cntusers
32

COUNT(*) は3ですが、実際に注文したユーザーは Alice と Bob の2人です。結合後の行は「ユーザー」ではなく「注文」の単位になっているので、COUNT(*) は注文の件数を数えています。

多を2つつなぐと SUM が膨らむ

さらに危険なのは、1つのテーブルに「多」の側を2つ結合した場合です。ユーザーごとの注文合計と問い合わせ件数を1つのクエリで出そうとします。

SUM が膨らむ例
SELECT u.name, SUM(o.amount) AS total, COUNT(t.id) AS tickets
FROM users u
LEFT JOIN orders o  ON o.user_id = u.id
LEFT JOIN tickets t ON t.user_id = u.id
GROUP BY u.id, u.name
ORDER BY u.id;
nametotaltickets
Alice90004
Bob20000
CarolNULL1
DaveNULL0

Alice の注文合計は 3000 + 1500 = 4500 のはずが 9000 に、問い合わせは2件のはずが4件になっています。集計前の行を見ると原因がわかります。

nameorder_idamountticket_id
Alice1013000201
Alice1013000202
Alice1021500201
Alice1021500202

注文2件と問い合わせ2件は互いに無関係なので、Alice の中で 2 × 2 = 4行の組み合わせができています。注文の金額はそれぞれ2回ずつ足され、問い合わせもそれぞれ2回ずつ数えられます。エラーは出ず、Bob や Carol のように片方が1件以下のユーザーでは正しい値になるため、テストデータが少ないと気づけません。

対処1: 結合する前に集計する

最も確実なのは、「多」の側を先に1ユーザー1行に集計してから結合する方法です。

先に集計してから結合する
SELECT u.name,
       COALESCE(o.total, 0)   AS total,
       COALESCE(t.tickets, 0) AS tickets
FROM users u
LEFT JOIN (
  SELECT user_id, SUM(amount) AS total
  FROM orders
  GROUP BY user_id
) o ON o.user_id = u.id
LEFT JOIN (
  SELECT user_id, COUNT(*) AS tickets
  FROM tickets
  GROUP BY user_id
) t ON t.user_id = u.id
ORDER BY u.id;
nametotaltickets
Alice45002
Bob20000
Carol01
Dave00

サブクエリの結果はどちらも user_id ごとに1行なので、結合しても行は増えません。結合する前に「このテーブルは結合キーに対して1行か、複数行か」を確認する習慣が、この種の不具合を防ぎます。

対処2: DISTINCT は応急処置にとどめる

行が増えたときに SELECT DISTINCT や SUM(DISTINCT o.amount) で帳尻を合わせる書き方は、慎重に扱う必要があります。上の例で SUM(DISTINCT o.amount) と書くと Alice の合計は 4500 になり、一見直ったように見えます。しかしこれは 3000 と 1500 がたまたま別の値だったからで、同じ金額の注文が2件あれば1件分しか足されません。

COUNT(DISTINCT o.id) のように、主キーを対象にした重複排除は正しく動きます。一方、金額のような「同じ値が複数あり得る列」への DISTINCT は、別の誤りを持ち込みます。また、SELECT DISTINCT で行を減らす方法は、いったん増やした行を並べ替えや比較で取り除くことになるため、行数が多いと処理も重くなります。行が増えた原因(結合の単位)を直すほうが先です。

セミジョインとアンチジョイン - 「あるか・ないか」だけを知りたいとき

「注文したことのあるユーザー」を知りたいだけなら、注文の内容は不要です。この場合に JOIN を使うと、行が増える問題を自分で呼び込むことになります。

JOIN で絞り込むと重複する
SELECT u.id, u.name
FROM users u
JOIN orders o ON o.user_id = u.id
ORDER BY u.id;
idname
1Alice
1Alice
2Bob

EXISTS(セミジョイン)

相手が「存在するかどうか」だけを条件にする操作をセミジョインと呼びます。SQL に SEMI JOIN というキーワードはなく、EXISTS や IN で書きます。

EXISTS で絞り込む
SELECT u.id, u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
)
ORDER BY u.id;
idname
1Alice
2Bob

EXISTS は相手が何件あっても左の行を複製しません。WHERE u.id IN (SELECT user_id FROM orders) と書いても、このデータでは同じ結果になります。

NOT EXISTS と LEFT JOIN ... IS NULL(アンチジョイン)

逆に「相手が存在しない行」を取り出す操作がアンチジョインです。「注文したことのないユーザー」は、次の2通りで書けます。

NOT EXISTS
SELECT u.id, u.name
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
)
ORDER BY u.id;
LEFT JOIN ... IS NULL
SELECT u.id, u.name
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL
ORDER BY u.id;

どちらも結果は同じです。

idname
3Carol
4Dave

LEFT JOIN ... IS NULL は、相手が見つからなかった行だけが NULL 埋めになる性質を利用しています。判定に使う列は、o.id のように相手のテーブルで NULL になり得ない列(主キーなど)にします。もともと NULL が入り得る列で判定すると、相手が存在する行まで拾ってしまいます。

NOT IN と NULL の落とし穴

同じことを NOT IN で書くと、このサンプルデータでは結果が変わります。

NOT IN(結果が0行になる)
SELECT u.id, u.name
FROM users u
WHERE u.id NOT IN (SELECT user_id FROM orders);

結果は0行です。Carol も Dave も返りません。原因はゲスト購入の注文 104 で、サブクエリの結果に NULL が1つ含まれているためです。3 NOT IN (1, 1, 2, NULL) は「3 は 1 でも 2 でもなく、かつ NULL でもない」という判定になり、最後の 3 = NULL が不明なので、全体も真になりません。PostgreSQL の公式ドキュメントにも、右側に等しい値がなく、かつ NULL を返す行が1つでもあれば、NOT IN の結果は真ではなく NULL になる、と明記されています。

サブクエリ側で WHERE user_id IS NOT NULL を付ければ Carol と Dave が返るようになりますが、列が NULL を許す限り同じ事故は再発し得ます。「存在しない」の判定には NOT EXISTS を使うと決めておくのが安全です。

LATERAL - 左の行ごとにサブクエリを実行する

通常、FROM 句の中のサブクエリは、同じ FROM 句にある他のテーブルの列を参照できません。LATERAL を付けると、それより前に書いたテーブルの列を参照できるようになります。「各ユーザーの最新の注文を1件ずつ」のように、行ごとに上位 N 件を取りたい場面で使います。

LATERAL(PostgreSQL の書き方)
SELECT u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN LATERAL (
  SELECT id, amount
  FROM orders
  WHERE orders.user_id = u.id
  ORDER BY id DESC
  LIMIT 1
) o ON true
ORDER BY u.id;
nameorder_idamount
Alice1021500
Bob1032000
CarolNULLNULL
DaveNULLNULL

NOTE

このクエリは手元では実行していません。結果表は、同じ内容を ROW_NUMBER() のウィンドウ関数で書いたクエリを SQLite で実行して得たものです。

サブクエリの中で u.id を参照している点が、通常のサブクエリとの違いです。PostgreSQL の公式ドキュメントは、LATERAL サブクエリに対して LEFT JOIN すると、サブクエリが行を返さない場合でも元の行が結果に残るので便利だ、と説明しています。上の例で Carol と Dave が残っているのはそのためです。

対応状況は次のとおりです。

  • PostgreSQL: FROM 句のサブクエリと関数呼び出しの前に LATERAL を書ける
  • MySQL: 8.0.14(2019年1月21日リリース)で LATERAL 付きの導出テーブルが追加された。それより前のバージョンでは使えない
  • SQLite: 手元の 3.54.0 で上のクエリを実行すると構文エラーになった。公式の SELECT 構文のページにも LATERAL の記載は見当たらない

LATERAL が使えない環境では、ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY id DESC) で順位を付けて1位だけを残す書き方が代わりになります。

データベースごとの対応差

ここまでに出てきた差をまとめます。

機能PostgreSQLMySQLSQLite
INNER / LEFT / CROSS JOIN対応対応対応
RIGHT JOIN対応対応3.39.0 以降
FULL OUTER JOIN対応非対応(UNION ALL で代替)3.39.0 以降
USING / NATURAL JOIN対応対応対応
LATERAL対応8.0.14 以降3.54.0 で構文エラーを確認
NULL どうしを等しいとみなす比較IS NOT DISTINCT FROM<=>IS(3.39.0 以降は IS NOT DISTINCT FROM も可)

SQLite は長いあいだ LEFT JOIN しか外部結合を持っていませんでしたが、3.39.0(2022年6月25日リリース)で RIGHT JOIN と FULL OUTER JOIN が追加されました。OS やアプリケーションに同梱された古い SQLite を使っている場合は、sqlite3 --version や SELECT sqlite_version(); でバージョンを確認してください。

USING や NATURAL JOIN でまとめられた結合列は、外部結合では「どちらの側の値が出るか」を意識する必要があります。MySQL のリファレンスマニュアルは、まとめられた列を COALESCE(t1.a, t2.a) として定義すると説明しており、SQLite の公式ドキュメントにも LEFT・RIGHT・FULL のそれぞれでどちらの列が使われるかの記述があります。複数のデータベースで同じクエリを動かす場合は、ON で条件を明示し、SELECT する列もテーブル別名付きで書いておくのが確実です。

性能について

この記事は JOIN の「意味と結果」に絞りましたが、性能面で押さえておきたい点を3つだけ挙げます。

  • 結合キーにインデックスを張る: orders.user_id のような外部キー側の列にインデックスがないと、結合のたびにテーブル全体を読むことになります。仕組みはデータベースインデックス入門で解説しています。
  • 書いた順に実行されるわけではない: データベースは統計情報をもとに結合の順序と方式(Nested Loop、Hash Join、Merge Join)を選びます。選ばれ方と EXPLAIN の読み方はクエリ実行計画入門にまとめています。
  • JOIN するか、別クエリに分けるか: ORM で関連データを取るときは、1回の JOIN にまとめる方法と、IN 句で別クエリにする方法があります。1対多の JOIN で行が増える性質は、ここでも転送量の増加として効いてきます。詳しくはN+1問題とはを参照してください。

ベン図による説明の限界

JOIN の解説では、2つの円が重なったベン図がよく使われます。INNER JOIN は重なった部分、LEFT JOIN は左の円全体、という図です。「どちらの側の行が残るか」を覚えるには役立ちますが、ベン図だけで理解しようとすると次の点でつまずきます。

  • 行が増えることを表せない: ベン図は集合の図なので、重なり部分の要素は1つずつです。Alice が2行になる、多と多で 2 × 2 = 4行になる、という JOIN の本質的な性質が図に現れません。
  • CROSS JOIN を描けない: すべての組み合わせを作る操作は、円の重なりでは表現できません。
  • 結合しているのは「同じ要素」ではない: ベン図が正確に表すのは UNION、INTERSECT、EXCEPT のような、同じ形の行どうしの集合演算です。JOIN は別々の列を持つ行を横に並べる操作で、条件も等値に限りません。

JOIN は「左の各行について、条件に合う右の行を探して横につなぐ。合う行が複数あればその数だけ行ができ、1つもなければ種類に応じて捨てるか NULL で埋める」と手続きとして捉えるほうが、行数まで正しく予測できます。

まとめ

  • JOIN の種類の違いは、相手が見つからなかった行を残すか捨てるか。INNER は捨てる、LEFT は左を残す、RIGHT は右を残す、FULL は両方残す。
  • 相手が複数いる行はその数だけ複製される。結果の行数は元のテーブルの行数と一致するとは限らない。
  • CROSS JOIN はすべての組み合わせ。結合条件の書き忘れで意図せず同じ状態になる事故に注意する。
  • NATURAL JOIN は同名の列を自動で結合条件にするため、スキーマ変更で黙って壊れる。ON か USING で明示する。
  • LEFT JOIN で右側のテーブルの条件を WHERE に書くと INNER JOIN と同じ結果になる。相手を絞る条件は ON に書く。
  • NULL は NULL と一致しない。NOT IN のサブクエリに NULL が混ざると結果が0行になるので、NOT EXISTS を使う。
  • 「多」を2つ結合すると SUM や COUNT が膨らむ。先に集計してから結合する。
  • 存在の有無だけが必要なら、JOIN ではなく EXISTS / NOT EXISTS を使う。
  • FULL OUTER JOIN は MySQL にはなく、SQLite は 3.39.0 以降。LATERAL は MySQL 8.0.14 以降。

JOIN のクエリを書いたら、実行する前に「結果は何行になるはずか」を一度予想してみてください。予想と実際の行数が違ったときが、結合の単位や条件の置き場所を見直すタイミングです。

参考リンク

関連記事

SQL の WITH 句(CTE)入門 - 共通テーブル式の書き方と再帰 CTE で階層データをたどる方法

SQL の WITH 句(CTE)入門 - 共通テーブル式の書き方と再帰 CTE で階層データをたどる方法

約60分

SQL の WITH 句(CTE: 共通テーブル式)と再帰 CTE(WITH RECURSIVE)を実行結果つきで整理します。サブクエリ・ビュー・一時テーブルとの違い、複数 CTE の連結、段階的な集計、再帰 CTE の構造と評価の進み方、連番・日付の生成、組織図のような階層データの展開と経路・深さの出し方、UNION と UNION ALL の差、無限ループの防ぎ方、マテリアライズとインライン化、データ変更を伴う CTE、DB ごとの対応差までを解説します。

SQL ウィンドウ関数入門 - OVER と PARTITION BY、ROW_NUMBER・RANK・LAG の使い分け

SQL ウィンドウ関数入門 - OVER と PARTITION BY、ROW_NUMBER・RANK・LAG の使い分け

約41分

SQL のウィンドウ関数を実行結果つきで整理します。OVER と PARTITION BY の意味、ROW_NUMBER・RANK・DENSE_RANK の違い、LAG / LEAD、累計と移動平均、ROWS と RANGE の差、LAST_VALUE の落とし穴、グループごとの上位N件、WHERE で使えない理由、DBごとの対応差までを解説します。

PostgreSQL の TOAST 入門 - 大きな値はどこに、どう保存されるのか

PostgreSQL の TOAST 入門 - 大きな値はどこに、どう保存されるのか

約20分

PostgreSQL が 2kB を超える行の値を圧縮したり別テーブルへ逃がしたりする TOAST の仕組みを、PostgreSQL 18.6 で実際に動かした結果とともに解説します。発動する条件、約2,000バイトずつのチャンク、PLAIN / EXTENDED / EXTERNAL / MAIN の4つの保存方式、pglz と lz4、テーブルの容量の見方、そして SELECT * や length() が TOAST を読みに行くコスト、UPDATE で TOAST が膨らむ理由と、EXPLAIN の SERIALIZE で隠れた読み込みを見る方法まで。

楽天市場のおすすめPR / 楽天アフィリエイト