SQLで複数条件のLEFT JOINを書く方法|ONとWHEREの違いと実例

SQLのLEFT JOINに複数条件を指定するときは、「どの左側の行を残すか」と「どの右側の行を結合するか」を分けて考えます。条件を増やすこと自体が高速化になるわけではありません。特に、右側の条件をON句からWHERE句へ移すと、残したかった未一致行が消えることがあります。

この記事では、AND・ORの書き方、ONとWHEREの違い、複数一致による行数の増加を押さえたうえで、従業員、注文履歴、プロジェクト、在庫の例を解説します。SQL例は主にMySQL 8.4を想定し、基本的な外部結合の考え方と、LIMIT・実行計画・統計更新などの製品別構文を区別します。

目次

LEFT JOINの基本構文と「左側を残す」の意味

SELECT a.column1, b.column2
FROM table_a AS a
LEFT JOIN table_b AS b
  ON a.key1 = b.key1
 AND a.key2 = b.key2;

ON句全体が真になる組み合わせを結合します。一つの左行に右行が2件一致すれば、結果も2行です。一つも一致しなければ、右側の列をNULLにした行を1行作ります。LEFT JOINは「左表と同じ行数になる」操作ではありません。さらにWHEREやHAVING、LIMITなどを適用すれば、最終結果から左行が除かれることがあります。

この論理的な意味は、PostgreSQLの結合・表式の説明やMicrosoftのLEFT OUTER JOINの説明でも確認できます。説明上の処理順と、最適化後の物理的な実行順は別です。

基本例:従業員と部署

departments.department_idが一意な部署IDで、employees.department_idが所属部署を表すとします。別名を付け、同名になりやすい列には表の別名も付けます。

SELECT e.employee_id, e.name, d.department_name
FROM employees AS e
LEFT JOIN departments AS d
  ON e.department_id = d.department_id
ORDER BY e.employee_id;

部署が未設定、または対応する部署行がなければ、従業員は残ってdepartment_nameがNULLになります。実在する部署の名称そのものがNULLの場合もあるため、名前のNULLだけで「未一致」とは判断できません。

複数条件はON句でANDにつなぐ

以下は、すべての注文を残し、有効な顧客情報だけを添える例です。customers.customer_idは主キー、orders.order_idも主キーとします。記事内では顧客IDの列名をcustomer_idに統一しています。

SELECT o.order_id, c.customer_id, c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.customer_id
 AND c.status = 'active'
ORDER BY o.order_id;

顧客IDが一致することと、顧客のstatusがactiveであることの両方が必要です。顧客がinactiveの注文も、顧客が存在しない注文も残り、結合した顧客列だけがNULLになります。「注文をactive顧客の分だけに絞った」わけではありません。

複合キーなら対応する列をすべて指定する

例えば会社ごとに部署IDを採番し、(company_id, department_id)の組み合わせが一意なら、部署IDだけでは別会社の部署に誤って一致します。次の例は先ほどとは異なる、会社IDを持つスキーマを想定しています。

SELECT e.employee_id, e.name, d.department_name
FROM company_employees AS e
LEFT JOIN company_departments AS d
  ON e.company_id = d.company_id
 AND e.department_id = d.department_id
ORDER BY e.employee_id;

ORを使う場合は括弧で意図を示す

SELECT o.order_id, c.customer_id, c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.customer_id
 AND (c.status = 'active' OR c.status = 'trial')
ORDER BY o.order_id;

括弧がない「ID一致 AND active OR trial」では、ANDがORより先に結び付くため、顧客IDの異なるtrial顧客まで一致し得ます。同じ列の候補値を列挙するだけなら、c.status IN (‘active’, ‘trial’)とも書けます。MySQL公式:演算子の優先順位

ONとWHEREの違いを小さなデータで確認する

説明用に、customersには「1・アオイ・active」「2・ベニ・inactive」の2行、ordersには次の4行があるとします。存在しない顧客IDは、未一致の動作を見せるためのテスト用データです。実際に外部キー制約があれば、そのような値の登録は通常拒否されます。

order_idorders.customer_id
1011
1022
103999
104NULL

前節のactive条件をONに置いたクエリの結果は次のとおりです。ここでのNULLは文字列ではなくSQLのNULLです。

order_id結合後のc.customer_idcustomer_name
1011アオイ
102NULLNULL
103NULLNULL
104NULLNULL

右側の条件をWHEREに置くと未一致行が消える

SELECT o.order_id, c.customer_id, c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.customer_id
WHERE c.status = 'active'
ORDER BY o.order_id;

この結果は注文101だけです。未一致行ではc.statusがNULLになり、「NULL = ‘active’」は真ではなくUNKNOWNになります。WHEREは真の行だけを残すため、未一致行は除外されます。この条件ならINNER JOINと同じ結果になり、MySQLは外部結合を内部結合に簡略化できる場合があります。MySQL公式:Outer Join Simplification

有効な顧客の注文だけが必要なら、この絞り込みは誤りではありません。意図を明確にするにはINNER JOINを使えます。「全注文を残したい」ときに同じ書き方をすると誤りになります。

SELECT o.order_id, c.customer_id, c.customer_name
FROM orders AS o
INNER JOIN customers AS c
  ON o.customer_id = c.customer_id
 AND c.status = 'active'
ORDER BY o.order_id;

OR c.customer_id IS NULLを足せば同じになる?

SELECT o.order_id, c.customer_id, c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.customer_id
WHERE c.status = 'active' OR c.customer_id IS NULL
ORDER BY o.order_id;

これもONにactive条件を置く場合と同じではありません。例では101・103・104は残りますが、102はinactive顧客に一度一致しているため、c.customer_idがNULLにならず除外されます。「条件を満たす右行がない場合も左行を残す」なら、右側の候補条件をONに書きます。

左側の行を絞る条件は目的に合わせてWHEREへ

SELECT o.order_id, o.total_amount, c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.customer_id
 AND c.status = 'active'
WHERE o.total_amount > 100
ORDER BY o.order_id;

これは100を超える注文だけを残し、その中で有効な顧客情報を結合します。一方、o.total_amount > 100をONに置くだけでは100以下の注文自体は消えず、右側の情報が付かないだけです。「左表の条件は必ずWHERE」という機械的な規則ではなく、残したい行の要件で置き場所を決めます。

NULLの扱い:未一致と値の欠落を区別する

通常の「=」では、両側がNULLでも真にはなりません。結合キーがNULLになり得る場合、NULL同士を同一の業務キーとみなすかは別途設計します。安易にCOALESCEで同じ代替値へ変換すると、無関係な欠損行同士を一致させる危険があります。PostgreSQL公式:比較演算とNULL

SELECT o.order_id,
       CASE WHEN c.customer_id IS NULL THEN 'unmatched'
            ELSE 'matched' END AS match_status,
       COALESCE(c.customer_name, 'Unknown') AS display_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.customer_id
 AND c.status = 'active'
ORDER BY o.order_id;

customer_idはNULLにならない主キーなので、結合後のNULLを未一致の判定に利用できます。COALESCEは表示を補うだけで、結合条件を変更しません。名前が未登録の実在顧客と未一致の注文は、どちらもUnknownと表示されるため、区別が必要ならmatch_statusのような判定を残します。

注文日と最終注文日を同時に結合する例の注意点

SELECT o.order_id, o.order_date, c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.customer_id
 AND o.order_date = c.last_order_date
ORDER BY o.order_id;

この条件は一般的な「各注文に顧客情報を付ける」処理ではありません。顧客IDに加え、注文日時が顧客表に保存されたlast_order_dateと完全一致するときだけ顧客情報が付きます。古い注文も左行として残りますが、日付が違えば顧客名はNULLです。全注文へ顧客情報を付けたいだけなら、日付の条件は外します。

last_order_dateの更新漏れ、NULL、日時の精度・タイムゾーンの違いも不一致の原因になります。また、この条件だけでは「各顧客の最新注文を1件選ぶ」ことはできません。ordersが左表なので注文のない顧客は出ず、同じ日時の注文が複数あれば、それらはすべて残ります。

全顧客に最新注文を最大1件添えるなら

次はウィンドウ関数が使えるMySQL 8.4向けの例です。注文日時がNULLの行は候補から除き、同じ日時なら大きいorder_idを選ぶ、と明確に決めます。このID順は同時刻の選択規則であり、IDが時刻の代わりになるという意味ではありません。

SELECT c.customer_id, c.customer_name,
       r.order_id, r.order_date, r.total_amount
FROM customers AS c
LEFT JOIN (
    SELECT order_id, customer_id, order_date, total_amount,
           ROW_NUMBER() OVER (
               PARTITION BY customer_id
               ORDER BY order_date DESC, order_id DESC
           ) AS rn
    FROM orders
    WHERE order_date IS NOT NULL
) AS r
  ON c.customer_id = r.customer_id
 AND r.rn = 1
ORDER BY c.customer_id;

rn = 1をONに置くことで、対象注文のない顧客も残ります。ここではすべての注文状態から最新1件を選びます。「完了注文の中で最新」が必要なら、派生表のWHEREにorder_status = ‘completed’も追加し、順位を付ける前に候補を限定します。構文はMySQLの順位関数、同順位時の扱いはPostgreSQLのウィンドウ関数の説明も参考になります。

実務例1:全顧客と条件に合う注文履歴

SELECT c.customer_id, c.customer_name,
       o.order_id, o.order_date, o.total_amount
FROM customers AS c
LEFT JOIN orders AS o
  ON c.customer_id = o.customer_id
 AND o.order_status = 'completed'
 AND o.order_date >= '2023-01-01 00:00:00'
ORDER BY c.customer_id, o.order_id;

全顧客を残し、2023年1月1日以降の完了注文を結合します。上限を設けていないため、2024年以降の注文も対象です。顧客に注文があっても、すべて未完了または対象期間外なら注文列はNULLになります。対象注文が3件なら、その顧客は3行になります。

1顧客1行で件数や合計を出す

SELECT c.customer_id, c.customer_name,
       COUNT(o.order_id) AS completed_order_count,
       COALESCE(SUM(o.total_amount), 0) AS completed_total
FROM customers AS c
LEFT JOIN orders AS o
  ON c.customer_id = o.customer_id
 AND o.order_status = 'completed'
 AND o.order_date >= '2023-01-01 00:00:00'
GROUP BY c.customer_id, c.customer_name
ORDER BY c.customer_id;

COUNT(o.order_id)なら、未一致行のNULLを数えないので対象注文なしは0件です。COUNT(*)だとLEFT JOINが作る未一致の1行も数えます。SUMはNULLの金額を集計しないため、ここではtotal_amountがNULLでない前提とします。金額欠損があり得るなら、集計結果のNULLを0に置き換えてよいかも含めて扱いを決めてください。MySQL公式:集約関数

実務例2:期限内の進行中タスクをプロジェクトに添える

SELECT p.project_id, p.project_name,
       t.task_id, t.task_name, t.assigned_to, t.due_date
FROM projects AS p
LEFT JOIN tasks AS t
  ON p.project_id = t.project_id
 AND t.status = 'in_progress'
 AND t.due_date < '2025-01-01 00:00:00'
ORDER BY p.project_id, t.task_id;

2024年12月31日いっぱいまでに期限が来る進行中タスクを結合します。全プロジェクトを残し、対象タスクがなければタスク列はNULLです。これは過去の期限も含む例で、2024年内だけを対象にするなら「t.due_date >= ‘2024-01-01 00:00:00’」もONへ追加します。

「due_date < ‘2024-12-31’」では12月31日は含まれません。日時列では「< 翌日の00:00:00」のような上限を含まない範囲にすると、その日の小数秒まで含められます。例の日時は同一の業務タイムゾーンのMySQL DATETIMEを想定しています。実際には列の型とタイムゾーンを確認し、アプリケーションから型を合わせた境界値を渡してください。MySQL公式:日付・時刻リテラル

実務例3:東京倉庫の正の在庫を商品に添える

SELECT p.product_id, p.product_name,
       i.inventory_id, i.stock_quantity, i.last_restock_date
FROM products AS p
LEFT JOIN inventory AS i
  ON p.product_id = i.product_id
 AND i.warehouse_location = 'Tokyo'
 AND i.stock_quantity > 0
ORDER BY p.product_id, i.inventory_id;

商品をすべて残し、東京倉庫にある正の在庫行を結合します。結果のNULLは「この条件に一致する行がない」という意味です。在庫行が未登録、東京以外にのみ在庫あり、東京の在庫が0、数量がNULL、といった状態をこれだけで区別できません。

同じ商品に東京のロットが複数あれば、商品は複数行になります。1商品1行で東京の在庫合計を出すなら、例えば次のように先に在庫を集約します。

SELECT p.product_id, p.product_name,
       COALESCE(s.tokyo_stock, 0) AS tokyo_stock
FROM products AS p
LEFT JOIN (
    SELECT product_id, SUM(stock_quantity) AS tokyo_stock
    FROM inventory
    WHERE warehouse_location = 'Tokyo'
    GROUP BY product_id
) AS s
  ON p.product_id = s.product_id
ORDER BY p.product_id;

この集計例は数量がNULLでない前提で、0や負数を含む東京の全在庫行を合計し、在庫行なしは0と表示します。前の「正の在庫行だけを表示する」例とは目的が違います。欠損を0と見なせない運用なら、COALESCEを外すか欠損・登録有無の列を追加します。

複数の1対多を結ぶと件数・合計が膨らむ

顧客に注文が2件、別の問い合わせが3件あり、それぞれを顧客IDだけでLEFT JOINすると、同じ顧客に2×3=6通りの組み合わせができます。そこで注文金額をSUMすると、注文が問い合わせ数だけ重複して集計されます。

  • まず「注文1行」「顧客1行」など結果の粒度を決める。
  • 顧客1行が必要なら、注文と問い合わせをそれぞれ顧客単位に集約してから結合する。
  • 最新1件が必要なら、順位や一意の選択規則で右側を1件に限定してから結合する。
  • DISTINCTやSUM(DISTINCT 金額)を根拠なく足さない。同額の別注文を誤って一つにまとめることがある。

条件に合う注文がない顧客だけを取得する

SELECT c.customer_id, c.customer_name
FROM customers AS c
LEFT JOIN orders AS o
  ON c.customer_id = o.customer_id
 AND o.order_status = 'completed'
WHERE o.order_id IS NULL
ORDER BY c.customer_id;

ここでは未一致行だけが欲しいので、右側の主キーに対するIS NULLをWHEREに置きます。「注文が一切ない顧客」ではなく「完了注文がない顧客」であり、未完了注文だけある顧客も含みます。存在確認だけなら、同じ条件を持つNOT EXISTSでも表現できます。

パフォーマンスは結果の正しさを確認してから改善する

ANDの記述順で高速化を保証しない

「最も絞り込める条件を先に書く」だけで高速になるとは限りません。オプティマイザは条件を変形し、索引や結合方式を選びます。ANDの左から右への短絡評価も前提にできません。まずはキー条件、右表の状態、期間など意味のまとまりで並べます。PostgreSQL公式:式の評価順序

ただし、複合インデックス内の列順は、SQLに条件を記述する順序とは別の問題です。索引の先頭列、等価条件、範囲条件と、他のクエリからの利用を合わせて検討します。

既存の主キー・索引を確認する

customers.customer_idが主キーなら、その検索のために同じ単一列の索引を重ねて追加する必要は通常ありません。MySQLの主キーは索引であり、PostgreSQLでも主キー作成時に一意索引が作成されます。MySQL公式:主キーの最適化/PostgreSQL公式:主キー

-- MySQL 8.4: 既存の索引を確認する読み取り用の例
SHOW INDEX FROM customers;
SHOW INDEX FROM orders;

顧客から完了注文と期間を探す例では、orders(customer_id, order_status, order_date)のような複合インデックスが検討候補になります。MySQLの複合索引は先頭からの列の並びが重要です。ただし、データ分布や対象件数、既存索引、書き込み頻度で有効性は変わり、「この索引を追加すれば必ず速い」とは言えません。MySQL公式:複合インデックス

索引には容量と更新コストがあります。本記事ではCREATE INDEXなどのスキーマ変更を実行する手順は示しません。既存設計と実行計画を調べ、必要な変更だけを検証環境で評価してください。キー列の型や照合順序をそろえ、索引列への不要な関数適用・型変換も確認します。

LIMITには一意に決まるORDER BYを組み合わせる

-- MySQL 8.4向け。customers.customer_idは主キー
SELECT o.order_id, o.order_date, c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.customer_id
 AND c.status = 'active'
ORDER BY o.order_date DESC, o.order_id DESC
LIMIT 100;

order_dateだけでは同時刻の順番が決まらないため、一意なorder_idも加えています。この例では右側が主キーで最大1件なので、結果の並びも一意に決まります。1対多の結合なら右側の一意キーも考慮し、LIMITが制限するのは結合後の行数であって左表の件数ではないことに注意します。

LIMITはMySQLとPostgreSQLで利用できますが、すべてのDB共通の構文ではありません。SQL ServerではTOP、またはORDER BYとOFFSET … FETCHを使います。返却量が減っても、ソートや結合の処理量まで必ず小さくなるわけではありません。大きいOFFSETや、ページ取得の間にデータが変わる場合の重複・欠落も別途検討します。MySQLのLIMIT最適化、PostgreSQLのLIMIT・OFFSET、SQL ServerのORDER BY・OFFSET・FETCH

実行計画と統計情報は製品別に確認する

-- MySQL 8.4: 推定実行計画を確認する例
EXPLAIN
SELECT c.customer_id, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
  ON c.customer_id = o.customer_id
 AND o.order_status = 'completed'
 AND o.order_date >= '2023-01-01 00:00:00';

選ばれた索引、推定行数、アクセス方法、結合の順序やソートなどを確認します。MySQLのEXPLAIN ANALYZEはクエリを実際に実行して測定するため、通常のEXPLAINと同じつもりで負荷の高いSQLへ付けないでください。検証環境と運用上の許可を確認して利用します。MySQL公式:EXPLAIN

統計情報の不足や古さが見積もりのずれに関係する場合は、製品の自動更新設定と運用手順を確認します。MySQLのANALYZE TABLE、PostgreSQLのANALYZE、SQL ServerのUPDATE STATISTICSは別構文です。MySQLのANALYZE TABLEは統計を更新し、ロックや負荷、既定でのバイナリログ記録を伴うので、単なる読み取り確認として実行しないでください。

参考:MySQL:ANALYZE TABLE/PostgreSQL:ANALYZE/SQL Server:UPDATE STATISTICS。統計更新も索引追加も一律の定期実行を勧めるものではなく、問題と環境に応じて判断します。

実行前に確認したいチェックリスト

  • 左表の全行を残すのか、左表自体も絞るのかを決めたか。
  • ONの複数条件すべてを満たす右行だけが一致することを確認したか。
  • 右側条件をWHEREへ移したことで未一致行が消えないか。
  • 0件・1件・複数件の一致、NULLのキーや値を試したか。
  • 複合キーの一部を省略していないか。ORの括弧は正しいか。
  • 1対多を重ねた結果、件数やSUMが膨らんでいないか。
  • 日付の下限・上限と、時刻・タイムゾーンの扱いが一致しているか。
  • LIMITの方言、安定した並び順、実行計画と実測の違いを確認したか。

検証範囲:本記事の基本的な結合・集約・順位付けの例は、ローカルのSQLiteと合成データで結果の意味を確認しています。日付・日時はテスト用に形式をそろえた文字列で扱いました。MySQL・PostgreSQL・SQL Serverの構文や運用上の注意は各公式資料と照合したもので、これらの製品での実機動作や性能を測定した結果ではありません。SQLiteの結果を各製品の型変換・日時・索引・実行計画の保証とはしないでください。

まとめ

複数条件のLEFT JOINでは、まずONで「一致の条件」を、WHEREで「最終的に残す行の条件」を表します。未一致行、複数一致、NULLを含む小さな例で結果を確かめることが、書き方の工夫より先です。そのうえで、既存索引と実行計画を調べ、必要な行数・列数に絞り、対象DBで性能を測定して改善しましょう。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次