複数テーブルを安全に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の公式資料と実行計画で行ってください。

コメント