LEFT JOIN を書いたのに、結果の行数が左のテーブルより少ない。条件をひとつ足しただけで、さっきまで見えていた行が消えた。SQLを書き慣れていても、この現象は定期的に人を止めます。
止まる理由は、たいてい構文ではなく覚え方の側にあります。LEFT JOIN を「左のテーブルの行は消えない結合」と覚えていると、行が消えた事実を説明できません。説明できないので、JOIN の種類を変えたり、条件を書き換えたりしながら、当たりを探すことになります。
この記事では、公式ドキュメントの定義を読み直して、条件を ON に置くか WHERE に置くかで結果が変わる理由を整理します。参照したのは、PostgreSQL の 7.2. Table Expressions(2026年10月14日に確認。ページ上の表示は PostgreSQL 18.6)と、MySQL 8.4 Reference Manual の Outer Join Simplification(同日に確認)です。
30秒で要点
LEFT OUTER JOINの定義は「左の行を残す」ではない。PostgreSQLの公式ドキュメントは「まず内部結合を行い、そのあと結合条件を満たさない左の行について、右側の列をNULLにした行を追加する」と記述している- 同じ条件を
ONに置くかWHEREに置くかで結果は変わる。公式ドキュメントは同じデータで3行と1行という2つの実行例を並べて示している - 理由は処理の順序。
ONの制限は結合の前に、WHEREの制限は結合の後に処理される。内部結合では問題にならず、外部結合では大きく効く - 判断軸は1本。その条件は「結合相手を選ぶ条件」か「結合が終わった後の行を絞る条件」か。前者は
ON、後者はWHERE - 確かめ方も1本。同じクエリの
ON版とWHERE版を両方実行し、結果の行数を並べる
「残す」と「追加する」は、同じことを言っていない
まず定義を読み直します。PostgreSQLの公式ドキュメントは LEFT OUTER JOIN を次のように定義しています。
First, an inner join is performed. Then, for each row in T1 that does not satisfy the join condition with any row in T2, a joined row is added with null values in columns of T2. Thus, the joined table always has at least one row for each row in T1.
訳すと、まず内部結合(両方のテーブルで条件が一致した組み合わせだけを返す結合)を行う。そのあと、T2のどの行とも結合条件を満たさないT1の各行について、T2側の列を NULL(値が入っていないことを表す印)にした結合行を追加する。結果として、結合後の表はT1の各行について少なくとも1行を持つ——となります。
ここで注目したいのは、動詞が「残す(keep)」ではなく「追加する(add)」であることです。この違いは言葉づかいの問題ではありません。処理の順番が違います。
「残す」という理解では、左の行がはじめから結果の中にいて、何もしなければそこに留まり続けるように聞こえます。実際の定義はその逆で、左の行はいったん内部結合の結果から外れ、そのあとで NULL を詰めた行として入れ直されています。
入れ直された行は、他の行と同じ1行です。特別な保護は付いていません。このあと何らかの条件で絞り込めば、他の行と同じように落ちます。「左の行は消えない」という覚え方は、ここで現実と食い違います。
なお、これは結果がどう決まるかを定める意味論の定義であって、データベースが実際にその順番で処理すると述べたものではありません。実行計画(データベースがクエリを処理する手順)の上で内部結合が先に実行されるとは限らない点は、分けて理解しておきます。
公式ドキュメントが並べている、3行と1行
定義だけでは実感が湧かないので、公式ドキュメントが載せている実行例を見ます。データはこうです。
t1の(num, name)は(1, a)(2, b)(3, c)t2の(num, value)は(1, xxx)(3, yyy)(5, zzz)
条件を ON に置いた場合:
SELECT * FROM t1 LEFT JOIN t2 ON t1.num = t2.num AND t2.value = 'xxx';
結果は 1|a|1|xxx と、右側が空の 2|b 3|c を合わせた3行です。
条件を WHERE に置いた場合:
SELECT * FROM t1 LEFT JOIN t2 ON t1.num = t2.num WHERE t2.value = 'xxx';
結果は 1|a|1|xxx の1行だけです。
同じデータ、同じ LEFT JOIN、同じ条件式で、3行と1行に分かれました。公式ドキュメントは後者の直前に "Notice that placing the restriction in the WHERE clause produces a different result" と添えています。つまりこれは、踏みやすい落とし穴として意図的に並べられた対比です。
ON 版の結果に注目してください。3|c の行は、t2 に num = 3 の行(3, yyy)が存在するにもかかわらず、右側が空になっています。t2.value = 'xxx' を満たさないため結合相手として選ばれず、不一致として NULL を詰めて追加された——定義どおりの振る舞いです。
そして WHERE 版では、この NULL を詰めた行が結果にいません。結合が終わった後に t2.value = 'xxx' で絞ると、右側が空の行は通らないからです。
なお、行の並び順は実装に依存するため、この出力例の順序を保証された順序として扱うことはしません。
理由は処理のタイミングにある
なぜ置き場所で結果が変わるのか。公式ドキュメントは理由を明示しています。
This is because a restriction placed in the
ONclause is processed before the join, while a restriction placed in theWHEREclause is processed after the join. That does not matter with inner joins, but it matters a lot with outer joins.
ON に置いた制限は結合の前に、WHERE に置いた制限は結合の後に処理される。内部結合では問題にならないが、外部結合では大きく効く——という記述です。
さらに WHERE 句を扱う節には、次の注記があります。
For outer joins there is no choice: they must be done in the
FROMclause. TheONorUSINGclause of an outer join is not equivalent to aWHEREcondition, because it results in the addition of rows (for unmatched input rows) as well as the removal of rows in the final result.
外部結合の ON 句や USING 句は WHERE 条件と等価ではない。理由は、最終結果から行を取り除くだけでなく、不一致の入力行に対して行の追加を引き起こすから。ここでも「追加」という動詞が鍵になっています。
WHERE にできるのは、できあがった行を取り除くことだけです。行を増やす力はありません。一方 ON は、どの行が結合相手として選ばれるかを決めるので、結果として追加される NULL 入りの行の内容まで変えます。できることの種類が違うので、同じ条件式を書いても同じ意味にはなりません。
ON 句には結合と直接関係しない条件も書けます。公式ドキュメントはそれを認めた上で "This can prove useful for some queries but needs to be thought out carefully"——役に立つ場合もあるが慎重に考える必要がある、と添えています。書ける場所と、書いてよい場所は別だという注意です。
なぜ内部結合の経験が、外部結合で裏切るのか
ここまでの説明を読んでから振り返ると、最初に「行が消えた」と驚いた理由も見えてきます。
公式ドキュメントが書いているとおり、内部結合では ON と WHERE を入れ替えても結果が変わりません。内部結合には不一致行の追加がないので、結合の前に絞っても後に絞っても、残る組み合わせは同じになります。
SQLを書き始めてしばらくの間、扱うのはたいてい内部結合です。その期間、「条件はどこに書いても同じ」という経験が積み上がります。この理解は一度も裏切られません。裏切られないので、検証されないまま前提になります。
外部結合を書くようになったとき、その前提は持ち込まれます。しかも条件を WHERE に足したクエリは、エラーを出しません。静かに、正しい構文のまま、意図と違う結果を返す。警告が出ないので、前提が間違っていることに気づく機会もありません。
気づくのは、たいてい集計結果の件数が合わないと指摘されたときです。そこから原因を探しても、「LEFT JOIN は左を残すはずだ」という覚え方を抱えたままでは、JOIN の周辺を見ても答えが出ません。原因は JOIN ではなく、条件をどこに置いたかにあるからです。
MySQLは、同じ現象を別の層から書いている
PostgreSQL以外ではどうか。MySQLの公式マニュアルにも関連する記述がありますが、述べている層が違います。
PostgreSQLの記述は、結果がどう決まるかという意味論でした。MySQLの記述は、オプティマイザ(クエリをどう実行するか決める仕組み)が行う書き換えの規則です。
Instead, MySQL converts the query to a query with no outer join operation if the
WHEREcondition is null-rejected. (That is, it converts the outer join to an inner join.) A condition is said to be null-rejected for an outer join operation if it evaluates to FALSE or UNKNOWN for any NULL-complemented row generated for the operation.
WHERE 条件が null-rejected であるとき、MySQLは外部結合を内部結合に変換する。null-rejected とは、その外部結合が生成した NULL 補完行に対して FALSE または UNKNOWN と評価される条件を指す——という記述です。
UNKNOWN は、真でも偽でもない第三の評価結果を表します。WHERE 句は真になった行だけを通すので、UNKNOWN の行も FALSE の行と同じく結果に残りません。
マニュアルは T1 LEFT JOIN T2 ON T1.A = T2.A に対して、null-rejected になる条件の例として T2.B IS NOT NULL T2.B > 3 T2.C <= T1.C T2.B < 2 OR T2.C > 1 を挙げています。そして「クエリ内の外部結合に対して WHERE 条件が null-rejected である場合、その外部結合は内部結合に置き換えられる」と明記しています。
つまり外部結合として書いたつもりのクエリが、内部結合として実行される。冒頭の「LEFT JOIN したのに行が消える」は、MySQLでは仕様として文書化された書き換えの結果でもあります。
ここで注意したいのは、2つのドキュメントを「同じことを言っている」とまとめないことです。PostgreSQLは結果としてそうなることを定義し、MySQLは最適化としてそう書き換えることを規定しています。観察される現象は似ていますが、記述の層が違うので、根拠として引くときは分けて扱います。
そして確認したのはこの2製品のドキュメントだけです。SQL標準の原典、SQL Server、Oracle、SQLiteは確認していません。「どのデータベースでも同じ」とは、この記事の範囲では言えません。
「WHEREが正解」になる場合もある
WHERE に置くと壊れる、と覚えると今度は逆に振れます。条件によっては WHERE が適切です。
MySQLのマニュアルは、null-rejected にならない条件の例の先頭に T2.B IS NULL を挙げています。内側テーブルの列が NULL であるという条件は、NULL 補完行に対して真になるので、外部結合を内部結合に書き換える条件には当たりません。
これは実務でよく使う「左にあって右にない行だけを取り出す」書き方に対応します。右側が空のまま追加された行だけを残したいのだから、結合が終わった後に絞るのが目的に合っています。WHERE が正しい置き場所です。
同じ WHERE でも、片方は意図を壊し、もう片方は意図を実現します。違いは句の名前ではなく、その条件が結合の前を見ているか、後を見ているかにあります。
なお、不一致行を取り出す書き方としてこの形が推奨されている、とまでは書きません。マニュアルはあくまで null-rejected 判定の例として挙げているだけです。
置き場所を決める、1行の判断軸
ここまでを1本の問いにまとめます。
その条件は、結合相手を選ぶ条件か。それとも、結合が終わった後の行を絞る条件か。
前者なら ON、後者なら WHERE です。迷ったら、条件を日本語で言い直してみます。
- 「
xxxという種類の明細だけを結合相手として見たい」→ 結合相手を選んでいる →ON - 「結合した結果のうち、
xxxの行だけを一覧に出したい」→ 結合後を絞っている →WHERE - 「明細が1件も無い顧客を知りたい」→ 結合後に、右が空の行を選んでいる →
WHERE
同じ t2.value = 'xxx' という式でも、意図は上の2つに分かれます。式を見ても判断できないのは、式が意図を持っていないからです。判断材料は書き手の側にあります。
内部結合では、この問いに答えなくても結果は同じです。答える必要が出るのは外部結合を書くときだけ——言い換えると、外部結合を書いた瞬間に、この問いが発生していることになります。
確かなことと、まだ確かではないこと
確かなこと: PostgreSQLの公式ドキュメントが LEFT OUTER JOIN を「内部結合のあとに不一致行を追加する」と定義していること。同じデータで ON 版が3行、WHERE 版が1行になる実行例が掲載されていること。ON の制限が結合前、WHERE の制限が結合後に処理されること。外部結合の ON / USING が WHERE 条件と等価ではないと明記されていること。MySQL 8.4のマニュアルが、null-rejected な WHERE 条件のとき外部結合を内部結合に変換すると規定していること(いずれも2026年10月14日に各公式ドキュメントで確認)。
まだ確かではないこと: SQL標準の原典がどう規定しているか。SQL Server・Oracle・SQLiteの挙動。これらは確認していないので、製品を越えた一般化はしません。また、ON と WHERE のどちらが速いかという性能の優劣も、この記事の参照範囲では確認していません。ORM(プログラムの言語からデータベースを操作する仕組み)が生成するSQLが条件をどちらに置くかも、製品や呼び出し方によって変わるため扱いません。
手元のクエリで試せる、最小の検証
いま「行が減った気がする」クエリがあるなら、確かめる手順は1つです。
同じクエリの ON 版と WHERE 版を両方実行して、結果の行数を並べる。
比べるのは1つの数字です。分母にあたるのが基準テーブル(左側)の行数、見るのが結果の行数、単位は行。外部結合が意図どおりなら、結果の行数は基準テーブルの行数を下回りません。下回っているなら、どこかで結合後の絞り込みが効いています。
-- 基準になる行数
SELECT count(*) FROM t1;
-- ON 版と WHERE 版の行数を見比べる
SELECT count(*) FROM t1 LEFT JOIN t2 ON t1.num = t2.num AND t2.value = 'xxx';
SELECT count(*) FROM t1 LEFT JOIN t2 ON t1.num = t2.num WHERE t2.value = 'xxx';
件数が変わったなら、そのクエリは外部結合の意図を持っていたということです。変わらなかったなら、そのクエリに外部結合は要りません。内部結合として書き直したほうが、読む人に意図が伝わります。
この確認は、一度やって終わりにせず置いておけます。重要な集計クエリなら、「基準テーブルの行数と結果行数の関係」をテストとして書いておく手があります。CI(コードを変更するたびに自動でテストを走らせる仕組み)で動かしておけば、後から条件を足した変更が意図を壊したときに気づけます。壊れたことに人が気づく経路を、集計結果への指摘以外にも用意しておく、という意味です。
結合で迷ったときに戻る3つの問い
- このクエリは、何件になるはずか。 答えを先に言えないなら、結果が正しいかも判定できません
- この条件は、結合の前を見ているか、後を見ているか。 式ではなく意図で決まります
- 外部結合である必要が、まだあるか。 条件を足していった結果、内部結合と同じ意味になっているクエリは珍しくありません
結合の挙動は、データベース製品ごとの既定値や、そもそものデータの持ち方と地続きです。製品による既定値の違いとその調べ方は分離レベルの既定値は製品ごとに違うで、テーブルを分けて持つという発想そのものはデータベースとは何かで扱っています。手元の製品のドキュメントを開く習慣は、どちらの話にも効いてきます。