SQLで複数テーブルをJOINしサブクエリを活用する実践的手法

複数テーブルを安全にJOINする要点は、最初に「結果を1行にする単位」を決め、1対多の子テーブルを必要な粒度まで集計してから結合することです。存在確認だけならEXISTS、親を欠落させたくないならLEFT JOIN、集計結果を再利用するならFROM句のサブクエリまたはCTEを使います。JOINを増やす前に粒度を言葉で書くと、重複行と二重集計の多くを防げます。

目次

まず選ぶべき形

目的向いている構文注意点
両方に存在する行だけ取得INNER JOIN結合キーの重複で行数が増える
注文のない顧客も残すLEFT JOIN右表の条件をWHEREへ置くと欠落しやすい
条件を満たす子行があるかだけ判定EXISTS子表の列を結果に出さない用途向け
子表を集計して親へ結合集計サブクエリまたはCTE結合前のGROUP BY粒度を明示する
単一値を列として出すスカラーサブクエリ2行以上返すとエラーになるDBが多い

例で使うテーブルと結果の粒度

ここでは、customers(顧客)、orders(注文)、order_items(注文明細)、products(商品)を想定します。顧客1人に注文が複数あり、1注文に明細が複数ある典型的な1対多の連鎖です。目標を「顧客1人につき1行で、支払済み注文数・合計額・最終注文日を出す」と決めます。

注文と明細をそのまま顧客へJOINし、顧客単位でCOUNT(*)すると、注文数ではなく明細数を数えてしまいます。先に注文単位へ集計してから顧客へ結合すれば、結果の粒度を守れます。

WITH order_totals AS (
    SELECT
        o.order_id,
        o.customer_id,
        o.ordered_at,
        SUM(oi.quantity * oi.unit_price) AS total_amount
    FROM orders AS o
    INNER JOIN order_items AS oi
        ON oi.order_id = o.order_id
    WHERE o.status = 'paid'
    GROUP BY o.order_id, o.customer_id, o.ordered_at
)
SELECT
    c.customer_id,
    c.customer_name,
    COUNT(ot.order_id) AS paid_order_count,
    COALESCE(SUM(ot.total_amount), 0) AS paid_total,
    MAX(ot.ordered_at) AS last_paid_at
FROM customers AS c
LEFT JOIN order_totals AS ot
    ON ot.customer_id = c.customer_id
GROUP BY c.customer_id, c.customer_name
ORDER BY c.customer_id;

order_totalsは注文1件につき1行です。そのため、外側のCOUNT(ot.order_id)は注文数を数えます。LEFT JOINなので注文がない顧客も残り、COALESCEで合計額のNULLを0にしています。CTEの代わりに同じSELECTを括弧で囲み、FROM句のサブクエリとしても書けます。

INNER JOINとLEFT JOINの使い分け

INNER JOINは結合条件を満たす組み合わせだけを返します。対してLEFT JOINは左側の行を必ず残し、対応する右側がない列をNULLにします。「注文のない顧客」「担当者未設定の案件」のような未対応行を見つけるにはLEFT JOINが適しています。

外部結合では、右表の条件を置く場所が結果を変えます。次の1本目は全顧客を残しつつ、2026年の注文だけを結合します。2本目は結合後にNULL行をWHEREで落とすため、実質的に注文のある顧客だけになります。

-- 注文がなくても顧客を残す
SELECT c.customer_id, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.customer_id
 AND o.ordered_at >= DATE '2026-01-01';

-- WHEREに置くとNULL側が除外される
SELECT c.customer_id, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.customer_id
WHERE o.ordered_at >= DATE '2026-01-01';

「右表を絞る条件なのか」「最終結果を絞る条件なのか」を日本語で説明できる位置へ置いてください。特にLEFT JOINの後ろへ右表の列を使うWHERE条件を追加するときは、未一致行を残す設計と矛盾しないか確認します。

存在確認はEXISTSで表現する

「2026年に支払済み注文が1件でもある顧客」を抽出するだけなら、注文表を結果へJOINする必要はありません。EXISTSはサブクエリが1行以上返すかを判定し、親行を重複させません。

SELECT c.customer_id, c.customer_name
FROM customers AS c
WHERE EXISTS (
    SELECT 1
    FROM orders AS o
    WHERE o.customer_id = c.customer_id
      AND o.status = 'paid'
      AND o.ordered_at >= DATE '2026-01-01'
);

この内側の問い合わせは外側のc.customer_idを参照する相関サブクエリです。結果列に注文情報が不要なら、JOIN後にDISTINCTで重複を消すより意図が明確です。NOT EXISTSにすれば該当注文がない顧客を抽出できます。

INとEXISTS、NULLの落とし穴

IN (subquery)も集合への所属判定に使えますが、NULLを含む三値論理、とくにNOT INでは期待外の結果になりやすい点に注意します。除外対象の列がNULLになり得るなら、相関条件を明示したNOT EXISTSを優先すると意図を確認しやすくなります。実際の挙動は利用中のDB製品と列制約でテストしてください。

二重集計を防ぐ設計チェック

  • 主キーを確認:各テーブルで1行を一意にする列を把握します。
  • 関係を確認:1対1、1対多、多対多のどれかを書き出します。
  • 期待行数を決める:顧客単位、注文単位、明細単位のどれかを固定します。
  • 子表を先に集約:多対多に近い結合をする前に、必要な単位へGROUP BYします。
  • DISTINCTを原因隠しにしない:なぜ重複したか説明できないまま除去しません。

性能を確認する順序

まず正しい結果を小さなデータで確認し、その後に実行計画を見ます。PostgreSQLではEXPLAINで計画を、実行を伴うEXPLAIN ANALYZEで実測を確認できます。後者は問い合わせを実行するため、更新SQLには本番で安易に使わず、この記事のようなSELECTでも負荷を見積もって実施します。

結合キーと検索条件に適切なインデックスがあるか、推定行数と実行行数が大きくずれていないか、全表走査がデータ量に対して妥当かを見ます。インデックスは読み取りを速くする一方、書き込みコストと容量を増やすため、計画を見ずに全列へ追加しません。SELECT *を避け、必要な列だけ返すことも転送量と可読性の両方に効きます。

検証とロールバック

検証では、既知の顧客を数件選び、元表から手計算した注文数・合計額と照合します。注文ゼロ、明細1件、明細複数、同額注文、NULLを含む行をテストデータへ用意します。JOIN前後の件数、主キーの重複数、合計値を比較し、境界日付も確認します。

SELECTだけならデータ変更のロールバックは不要ですが、ビュー化、インデックス追加、バッチへの組み込みは別です。変更前の定義と実行計画を保存し、ステージング環境で比較してから反映します。結果が合わない場合は、最後に追加したJOINを外し、各サブクエリを単独実行して、行が増えた段階を特定します。

よくある質問

Q. JOINとサブクエリはどちらが速いですか?
構文だけでは決まりません。オプティマイザーが同等の計画へ変換することもあります。正しさと意図の明確さを先に取り、実データの実行計画で比較します。

Q. CTEは必ず一時表になりますか?
DB製品とバージョン、問い合わせ内容で扱いが異なります。可読性のために使う場合でも、性能判断は対象DBの公式資料と実行計画で行ってください。

公式情報

この記事を書いた人

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

コメント

コメントする

目次