青の統計学-DS Playground-

1対多と重複: JOIN後に行数が増える理由

Stage 3 — 第4章 | データ分析基礎カリキュラム 推定学習時間:35〜45分 | 難易度:★★★☆☆


この章で学ぶこと

JOINを使い始めると、「JOINしただけなのに行数が増えた」という場面に出会います。 これはSQLのミスとは限りません。 テーブル同士の関係が 1対多 になっていると、JOIN後に行が増えるのは自然なことです。

この章では、JOIN後の重複や行数増加をどう理解するかを学びます。 分析で合計や件数を出すときに、ここを理解していないと結果が大きくずれることがあります。

この章を終えると、こんなことができるようになります:

  • 1対多の関係を説明できる
  • JOIN後に行数が増える理由を理解できる
  • 注文単位と明細単位の違いを見分けられる
  • 集計前にデータの粒度を確認できる

1. 1対多とは何か

1対多とは、片方のテーブルの1行に対して、もう片方のテーブルの複数行が対応する関係です。

flowchart LR A["customers
1行 = 顧客1人"] -->|"1対多"| B["orders
1行 = 注文1件"] B -->|"1対多"| C["order_items
1行 = 明細1行"]

顧客1人に注文が3件あれば、JOIN後は顧客情報が3行に繰り返されます。 さらに注文明細までつなぐと、1注文あたりの商品数ぶん行が増えます。 JOINは文法の問題だけでなく、行数がどう変わるか の問題として捉えましょう。

ECデータでは、よく次のような関係があります。

関係 説明
顧客と注文 1人の顧客が複数回注文することがある
注文と注文明細 1つの注文に複数の商品が含まれることがある
商品と注文明細 1つの商品が複数の注文で購入されることがある

たとえば、1人の顧客が3回注文していれば、customers の1行に対して orders の3行が対応します。 この状態でJOINすると、顧客情報は3行に繰り返されます。


2. 顧客と注文をJOINすると行数が増える

次のSQLは、顧客と注文をつなぎます。

SELECT
            c.customer_id,
            c.customer_name,
            o.order_id,
            o.order_date,
            o.total_amount
          FROM customers AS c
          INNER JOIN orders AS o
            ON c.customer_id = o.customer_id;
          

もしC001の顧客が3件注文していれば、結果にはC001が3行出ます。 これは重複しているように見えますが、注文単位で見ると正しい結果です。

customer_id customer_name order_id total_amount
C001 Aさん O001 5200
C001 Aさん O003 3400
C001 Aさん O008 7600

この結果の1行は「顧客1人」ではなく「注文1件」です。 JOIN後の結果を読むときは、1行が何を表しているかを必ず確認しましょう。


3. 注文と注文明細をJOINするとさらに増える

注文テーブル orders は、1行が注文1件です。 一方、order_items は、1行が注文に含まれる商品1行です。

SELECT
            o.order_id,
            o.order_date,
            oi.product_id,
            oi.quantity,
            oi.unit_price
          FROM orders AS o
          INNER JOIN order_items AS oi
            ON o.order_id = oi.order_id;
          

1つの注文に3商品が含まれていれば、その注文はJOIN後に3行になります。

この結果の1行は「注文1件」ではなく「注文明細1行」です。 注文金額を扱うときは、この違いが重要です。


4. 合計金額の二重計上に注意する

次のSQLは、JOIN後に orders.total_amount を合計しています。

SELECT
            SUM(o.total_amount) AS total_sales
          FROM orders AS o
          INNER JOIN order_items AS oi
            ON o.order_id = oi.order_id;
          

このSQLは危険です。 1つの注文に複数の商品明細がある場合、同じ total_amount が明細の数だけ繰り返されるため、売上が大きく見えてしまいます。

注文金額を合計したいなら、注文テーブルだけで集計するほうが自然です。

SELECT
            SUM(total_amount) AS total_sales
          FROM orders
          WHERE status = 'completed';
          

一方で、商品別の売上を見たいなら、明細単位で計算します。

SELECT
            oi.product_id,
            SUM(oi.quantity * oi.unit_price) AS item_sales
          FROM order_items AS oi
          GROUP BY oi.product_id;
          

どのテーブルのどの粒度で集計するかを意識しましょう。


5. JOIN前に粒度を確認する

JOINを書く前に、「結果の1行を何にしたいか」を決めます。 ここが曖昧なままJOINすると、集計結果がずれやすくなります。

見たいもの 結果の1行 主に使うテーブル
注文ごとの売上 注文1件 orders
商品ごとの販売数 商品1つ order_items, products
顧客ごとの購入回数 顧客1人 customers, orders
注文明細ごとの金額 明細1行 order_items

たとえば「顧客ごとの購入回数」を見たいなら、JOIN後に顧客単位へ GROUP BY c.customer_id で戻す必要があります。 一方、「注文に含まれる商品」を見たいなら、明細単位の行数増加は自然です。

SQLを書く前に、次の順で考えると安全です。

  1. 最終結果の1行は何か
  2. その1行を作るために必要なテーブルはどれか
  3. JOINすると行数は増えるか
  4. 集計で元の粒度に戻す必要があるか

6. JOIN前後の行数を確認する

JOINの結果が想定通りか確認するには、行数を数えるのが有効です。

SELECT COUNT(*) AS customer_rows
          FROM customers;
          
SELECT COUNT(*) AS order_rows
          FROM orders;
          
SELECT COUNT(*) AS joined_rows
          FROM customers AS c
          INNER JOIN orders AS o
            ON c.customer_id = o.customer_id;
          

JOIN後の行数が増えていても、1対多の関係なら自然です。 ただし、想定以上に増えている場合は、JOIN条件が不足している可能性があります。

分析SQLを書くときは、最初に数行だけ確認し、必要なら COUNT(*) で行数も確認しましょう。


7. 重複を消す前に意味を考える

JOIN後に同じ顧客IDが何度も出ると、すぐに DISTINCT を使いたくなるかもしれません。 しかし、重複に見える行が分析上必要な行であることも多いです。

見えている状態 まず考えること
顧客IDが複数回出る その顧客が複数注文しているだけではないか
注文IDが複数回出る その注文に複数商品が含まれているだけではないか
商品IDが複数回出る 複数注文で同じ商品が買われているだけではないか

DISTINCT は結果を整える道具ですが、原因を理解せずに使うと、必要な情報まで消してしまうことがあります。


8. 良くない修正と良い修正

JOIN後に行数が増えたとき、原因を見ずに DISTINCT を足すのは危険です。

SELECT DISTINCT
            c.customer_id,
            c.customer_name,
            o.total_amount
          FROM customers AS c
          INNER JOIN orders AS o
            ON c.customer_id = o.customer_id;
          

このSQLは一見すっきりしますが、「顧客単位で見たいのか」「注文単位で見たいのか」が曖昧です。 顧客ごとの購入金額を見たいなら、顧客単位に集計します。

SELECT
            c.customer_id,
            c.customer_name,
            COUNT(o.order_id) AS order_count,
            SUM(o.total_amount) AS customer_sales
          FROM customers AS c
          INNER JOIN orders AS o
            ON c.customer_id = o.customer_id
          GROUP BY
            c.customer_id,
            c.customer_name;
          

良い修正は、行を無理に消すことではなく、目的の粒度に合わせて集計することです。


9. 実務でよくある罠:注文数を数えたつもりが明細数を数えている

次のSQLは、一見「顧客ごとの注文数」を数えているように見えます。

SELECT
            c.customer_id,
            COUNT(o.order_id) AS order_count
          FROM customers AS c
          LEFT JOIN orders AS o
            ON c.customer_id = o.customer_id
          LEFT JOIN order_items AS oi
            ON o.order_id = oi.order_id
          GROUP BY c.customer_id;
          

しかし order_items までJOINしているため、1注文に商品が3つあれば同じ order_id が3行に増えます。 その結果、COUNT(o.order_id)注文数ではなく注文明細数に近い値 になります。

見たいもの 必要な粒度 安全な数え方
顧客ごとの注文数 注文1件 COUNT(DISTINCT o.order_id)
顧客ごとの購入商品数 明細1行 COUNT(oi.product_id) または SUM(oi.quantity)
顧客ごとの売上 注文1件 SUM(o.total_amount)(明細JOINなし)

SQLが正しくても、JOINしたテーブルの粒度を無視すると 間違った分析 になります。 集計前に「この COUNT は何の単位を数えているか」を必ず確認しましょう。


ミニ演習

次の分析では、最終結果の1行が何を表すべきか考えてください。

  1. 顧客ごとの購入回数を見たい。
  2. 商品カテゴリごとの売上を見たい。
  3. 注文ごとの商品数を見たい。
  4. 顧客ごとの直近注文日を見たい。

それぞれについて、JOIN後に GROUP BY が必要かどうかも考えてみましょう。


まとめ

観点 確認すること
粒度 JOIN後の1行が何を表すか
1対多 片方の1行にもう片方の複数行が対応するか
行数 JOIN前後で行数がどう変わるか
集計 合計や件数が二重計上になっていないか

この章のキーメッセージ: JOIN後に行数が増えるのは、1対多の関係では自然です。重複を消す前に、結果の1行が顧客・注文・明細のどの単位なのかを確認しましょう。


この章の確認

  1. 顧客と注文の関係が1対多になる理由を説明してください。
  2. ordersorder_items をJOINすると、注文IDが複数行に出ることがあるのはなぜですか?
  3. JOIN後に orders.total_amount を合計すると、二重計上が起きることがある理由を説明してください。
  4. JOIN結果に同じIDが複数回出たとき、DISTINCT の前に何を確認すべきですか?

関連演習