2026年5月26日
2026年5月26日
MySQLのJOINの種類と使い分け|INNER・LEFT・RIGHT・CROSSの違いをWordPressのテーブルで理解する
MySQL の JOIN は、実務ではほぼ「INNER JOIN(両方にあるものだけ)」と「LEFT JOIN(左側は全部残す)」の二つで足ります。使い分けの基準は、「相手側に対応する行が無いとき、その行を結果に残したいかどうか」です。この記事では、WordPress のテーブルを例に各 JOIN の違いと書き方を示し、行が増える・LEFT JOIN が INNER JOIN のようになるといった、よくあるつまずきと確認方法を説明します。
例に使う WordPress のテーブル
WordPress のデータベースは、JOIN を練習するのにちょうどよい構造をしています。この記事では次のテーブルを使います。テーブル名の接頭辞は標準の wp_ として書いていますが、wp-config.php の $table_prefix で変えている場合は読み替えてください。
wp_posts:投稿・固定ページなど。主キーはID、投稿者はpost_author。wp_postmeta:投稿に付くカスタムフィールド。post_idでwp_posts.IDと結び付きます。1件の投稿に多数の行があります。wp_users:ユーザー。IDがwp_posts.post_authorに対応します。wp_term_relationships・wp_term_taxonomy・wp_terms:カテゴリーやタグと投稿の関係。
ここで紹介する SQL は SELECT で中身を確認するためのものです。本番のデータベースで試す場合も、読み取りだけなら内容は変わりません。後半で触れる削除系の SQL は、バックアップを取ってステージング環境で試してから使ってください。
INNER JOIN:両方に対応する行があるものだけ
INNER JOIN は、結合条件に一致する行が両方のテーブルにあるものだけを返します。投稿と投稿者名を並べる例です。
SELECT p.ID, p.post_title, u.display_name
FROM wp_posts AS p
INNER JOIN wp_users AS u ON u.ID = p.post_author
WHERE p.post_type = 'post'
AND p.post_status = 'publish';
投稿者のユーザーが削除されて wp_users に存在しない投稿は、この結果に出てきません。「対応する相手がいないものは要らない」場合に使います。MySQL では JOIN とだけ書いても INNER JOIN と同じ意味になります。
テーブルを三つ以上つなぐ場合も、INNER JOIN を続けて書きます。カテゴリーのスラッグが news の公開済み投稿を探す例です。
SELECT p.ID, p.post_title
FROM wp_posts AS p
INNER JOIN wp_term_relationships AS tr ON tr.object_id = p.ID
INNER JOIN wp_term_taxonomy AS tt
ON tt.term_taxonomy_id = tr.term_taxonomy_id
AND tt.taxonomy = 'category'
INNER JOIN wp_terms AS t ON t.term_id = tt.term_id
WHERE t.slug = 'news'
AND p.post_type = 'post'
AND p.post_status = 'publish';
wp_term_relationships は投稿と分類を結ぶ中間テーブルで、term_id ではなく term_taxonomy_id を持っている点がつまずきやすいところです。wp_terms の term_id と直接結び付けると、結果が合わなくなることがあります。
LEFT JOIN:左側のテーブルは全部残す
LEFT JOIN(LEFT OUTER JOIN)は、左側(FROM に書いたテーブル)の行をすべて残し、右側に対応する行が無ければその列を NULL にします。
アイキャッチ画像の有無を一覧にする例です。アイキャッチ画像は wp_postmeta の _thumbnail_id というキーに保存されています。
SELECT p.ID, p.post_title, pm.meta_value AS thumbnail_id
FROM wp_posts AS p
LEFT JOIN wp_postmeta AS pm
ON pm.post_id = p.ID
AND pm.meta_key = '_thumbnail_id'
WHERE p.post_type = 'post'
AND p.post_status = 'publish';
アイキャッチ画像が無い投稿も一覧に残り、thumbnail_id が NULL になります。ここで INNER JOIN を使うと、画像の無い投稿は結果から消えてしまいます。
「対応する行が無いもの」を探す
LEFT JOIN と IS NULL を組み合わせると、「相手が存在しない行」を探せます。上の例を変えて、アイキャッチ画像が設定されていない投稿だけを抜き出します。
SELECT p.ID, p.post_title
FROM wp_posts AS p
LEFT JOIN wp_postmeta AS pm
ON pm.post_id = p.ID
AND pm.meta_key = '_thumbnail_id'
WHERE p.post_type = 'post'
AND p.post_status = 'publish'
AND pm.meta_id IS NULL;
同じ考え方で、削除された投稿のカスタムフィールドが残っている「孤立したデータ」も見つけられます。
SELECT COUNT(*)
FROM wp_postmeta AS pm
LEFT JOIN wp_posts AS p ON p.ID = pm.post_id
WHERE p.ID IS NULL;
同じ結果は NOT EXISTS でも書けます。どちらが読みやすいかで選んでよく、MySQL のバージョンによって実行計画が変わることもあるため、気になる場合は後述の EXPLAIN で比べます。
RIGHT JOIN と FULL OUTER JOIN
RIGHT JOIN は LEFT JOIN の左右を逆にしたもので、右側のテーブルをすべて残します。テーブルを書く順番を入れ替えれば LEFT JOIN で同じことができるため、実務では LEFT JOIN にそろえて書くことが多いです。一つの SQL に LEFT と RIGHT が混ざると、どちらのテーブルを基準にしているのか読み取りにくくなります。
両方のテーブルの行をすべて残す FULL OUTER JOIN は、MySQL には用意されていません(2026年10月現在)。必要な場合は、LEFT JOIN と RIGHT JOIN の結果を UNION でまとめて代わりにします。
SELECT a.id, b.id
FROM table_a AS a LEFT JOIN table_b AS b ON b.a_id = a.id
UNION
SELECT a.id, b.id
FROM table_a AS a RIGHT JOIN table_b AS b ON b.a_id = a.id;
CROSS JOIN と自己結合
CROSS JOIN は、結合条件なしで「左の全行 × 右の全行」の組み合わせを作ります。100行と100行なら1万行になるため、日付の一覧と店舗の一覧から全組み合わせの表を作る、といった目的がはっきりした場面以外では使いません。結合条件の書き忘れでも同じことが起きるので、思ったより結果が多いときはまず ON を確認してください。
同じテーブルを別名を付けて二回使う「自己結合」もよく使われます。固定ページとその親ページを並べる例です。
SELECT child.post_title AS page, parent.post_title AS parent_page
FROM wp_posts AS child
LEFT JOIN wp_posts AS parent ON parent.ID = child.post_parent
WHERE child.post_type = 'page'
AND child.post_status = 'publish';
親を持たないページも残すために、ここでは LEFT JOIN を使っています。
よくあるつまずき
LEFT JOIN なのに行が減る
LEFT JOIN の右側のテーブルに対する条件を WHERE に書くと、右側が NULL の行が条件で落とされ、結果として INNER JOIN と同じになります。
-- 意図どおりにならない例:画像の無い投稿が消える
SELECT p.ID, pm.meta_value
FROM wp_posts AS p
LEFT JOIN wp_postmeta AS pm ON pm.post_id = p.ID
WHERE pm.meta_key = '_thumbnail_id';
右側のテーブルを絞り込む条件は ON に書きます。前述のアイキャッチ画像の例で pm.meta_key = '_thumbnail_id' を ON に入れていたのは、この理由です。「結合の相手を選ぶ条件は ON、結果全体を絞る条件は WHERE」と覚えておくと迷いにくくなります。
行が増えて件数が合わない
wp_postmeta のように、一つの投稿に多数の行があるテーブルを条件なしで結合すると、投稿1件がメタデータの行数分だけ繰り返されます。件数を数えると実際より多くなるのはこのためです。
- 結合する相手を
ONの条件で1行に絞る(meta_keyを指定するなど)。 - 「存在するかどうか」だけを知りたいなら、JOIN ではなく
EXISTSを使う。 - 集計するなら
COUNT(DISTINCT p.ID)のように、重複を除いて数える。
DISTINCT で見かけの重複を消す方法もありますが、原因を隠してしまうことがあるため、まずなぜ行が増えたのかを確認してから使ってください。
JOIN が遅いときの確認
JOIN の速さは、結合に使う列にインデックスがあるかどうかで大きく変わります。WordPress の標準のテーブルでは、wp_postmeta.post_id や wp_term_relationships.term_taxonomy_id などの主要な結合列にはインデックスがあります。遅い場合は、SQL の先頭に EXPLAIN を付けて実行計画を確認します。
EXPLAIN SELECT p.ID
FROM wp_posts AS p
INNER JOIN wp_postmeta AS pm ON pm.post_id = p.ID
WHERE pm.meta_key = '_price';
type 列が ALL(全件走査)になっているテーブルや、rows 列の見積もりが極端に大きいテーブルがあれば、そこが遅さの原因になっている可能性があります。wp_postmeta の meta_value は長い文字列型のため、値で絞り込む条件は速くなりにくい点にも注意が必要です。インデックスの追加はテーブルの構造を変える作業なので、バックアップを取り、ステージングで効果を確かめてから行います。
WordPress のコードで JOIN を書くとき
テーマやプラグインの中で投稿を取り出すだけなら、SQL を直接書かずに WP_Query の tax_query や meta_query を使う方が安全です。WordPress 側で JOIN を組み立て、キャッシュも効きます。
どうしても SQL を書く場合は、$wpdb->posts のようにテーブル名をプロパティから取り、利用者の入力が入る値は $wpdb->prepare() で埋め込みます。
global $wpdb;
$slug = sanitize_title( wp_unslash( $_GET['cat'] ?? '' ) );
$sql = $wpdb->prepare(
"SELECT p.ID, p.post_title
FROM {$wpdb->posts} AS p
INNER JOIN {$wpdb->term_relationships} AS tr ON tr.object_id = p.ID
INNER JOIN {$wpdb->term_taxonomy} AS tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'category'
INNER JOIN {$wpdb->terms} AS t ON t.term_id = tt.term_id
WHERE t.slug = %s AND p.post_status = 'publish'",
$slug
);
$rows = $wpdb->get_results( $sql );
取得した値を画面に出すときは、esc_html() などでエスケープします。
孤立したデータを JOIN で探して削除する、といったメンテナンスでは、DELETE pm FROM wp_postmeta AS pm LEFT JOIN ... WHERE p.ID IS NULL のように複数テーブルを使った DELETE も書けます。ただし条件を一つ誤ると必要なデータまで消えるため、先に同じ条件の SELECT で件数と中身を確認し、データベースのバックアップを取ってから実行してください。
まとめ
- 使い分けの基準は「相手が無い行を残すか」。残さないなら INNER JOIN、残すなら LEFT JOIN。
- RIGHT JOIN は左右を入れ替えた LEFT JOIN で書ける。FULL OUTER JOIN は MySQL に無いため、
UNIONで代わりにする。 - LEFT JOIN で右側を絞る条件は
ONに書く。WHEREに書くと INNER JOIN と同じ結果になる。 - 一対多のテーブルを結合すると行が増える。条件で1行に絞るか、
EXISTSやCOUNT(DISTINCT ...)を使う。 - 遅いときは
EXPLAINで確認し、WordPress のコードではWP_Queryか$wpdb->prepare()を使う。