JOINで表をつなぐ:顧客と注文を結びつけて分析する|マーケターのためのSQL入門 第4回
監修田中 磨生株式会社Abin 代表取締役
株式会社Abin代表取締役。AI SaaSの上場企業やHR系ベンチャーのマーケティング責任者を経て、2023年BtoBマーケティング特化のコンサルティング会社株式会社Abinを設立。創業から30社以上のBtoBマーケティングの戦略立案から実行までを行う。2025年には株式会社プロベルの役員に就任。NYマーケティング株式会社の執行役員も務める。
執筆Abin編集部
この回で分かること:①JOINで2つの表を共通の列(キー)でつなぎ、「流入元ごとの売上」のように1つの表だけでは出せない数字を出す方法、②INNER JOINとLEFT JOINの違いと、つないだときに行が「消える」「増える」落とし穴、③WITH句を使って、先にまとめてからつなぐ書き方。前回は、GROUP BYと集計関数で数字をまとめる方法を学びました。今回は、複数の表を組み合わせて、分析の幅を一気に広げます。

この回の例も、架空の通販「ツギノテ雑貨店」のサンプルデータで実際に実行し、結果を確かめたものです。データは説明のために作った架空のものです。
なぜ表をつなぐのか
第1回で学んだとおり、データベースではテーマごとに表を分けて持ちます。ツギノテ雑貨店なら、顧客の情報(都道府県・登録日・流入元)は customers に、注文の情報(注文日・カテゴリー・金額)は orders に入っています。
ところが、マーケティングで知りたいことの多くは、2つの表にまたがっています。
- 検索広告から来た顧客は、どれくらい買っているか(流入元は customers、金額は orders)
- 東京都の顧客がよく買うカテゴリーは何か(都道府県は customers、カテゴリーは orders)
- 一度も買っていない会員は誰か(会員は customers、購入は orders)
これに答えるには、2つの表を customer_id でつないで、1つの表として扱う必要があります。そのための書き方がJOINです。表計算ソフトでいえば、VLOOKUP関数で別のシートから値を引いてくる操作に近いものです。
INNER JOIN:両方の表にあるものだけをつなぐ
まずは、6月の注文に、その注文をした顧客の流入元を付けてみましょう。
SELECT o.order_id, o.customer_id, c.channel, o.amount
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
WHERE o.order_date >= '2026-06-01'
ORDER BY o.order_date;
| order_id | customer_id | channel | amount |
|---|---|---|---|
| O109 | C006 | 検索広告 | 3600 |
| O110 | C004 | 自然検索 | 2200 |
| O111 | C007 | SNS | 9800 |
| O112 | C002 | 自然検索 | 3100 |
| O113 | C006 | 検索広告 | 7400 |
| O114 | C001 | 検索広告 | 4500 |
書き方のポイントは3つです。
- FROM 表A JOIN 表B ON 条件:ONのあとに「どの列とどの列が同じなら、行をつなぐか」を書きます。ここでは o.customer_id = c.customer_id です。
- 表の別名:FROM orders AS o のように、表にも短い別名を付けられます。2つの表に同じ名前の列(customer_id)があるので、「o.customer_id」「c.channel」のように、どちらの表の列かを書き分けます。
- JOINのあとにWHEREやGROUP BY:つないだ結果は1つの表として扱えるので、これまで学んだWHERE・GROUP BY・ORDER BYがそのまま使えます。
「消えた行」に気づく
ここで、6月30日の注文 O115 が結果に入っていないことに気づいたでしょうか。O115 は顧客 C009 の注文ですが、サンプルデータの customers には C009 がいません(退会して顧客の情報が消えた、データの取り込み漏れなど、実務でもよく起こる状況を想定しています)。
INNER JOINは、ONの条件に合う相手が両方の表にある行だけを残します。相手が見つからない行は、黙って結果から消えます。実際、つなぐ前の注文は15件ですが、INNER JOINでつないだ後に数えると14件になります。
SELECT COUNT(*)
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id;
| COUNT(*) |
|---|
| 14 |
エラーも警告も出ないので、気づかないまま「売上の合計」を出すと、O115 の1,900円が抜けた数字を報告してしまいます。JOINの前後で件数を数えて、減っていないかを確かめる習慣をつけましょう。
注文の表(15件)と顧客の表を INNER JOIN でつなぐと、14件になりました。考えられる理由として最も適切なものはどれでしょう?
- INNER JOIN は、必ず1件減らす決まりがあるから
- 顧客の表に存在しない customer_id の注文があり、相手が見つからず結果から消えたから
- 注文の表に重複した行があったから
答えと解説
正解は2です。INNER JOINは、両方の表に相手がいる行だけを残します。顧客の表にない C009 の注文 O115 は、つなぐ相手がいないため結果から消えました。エラーにならないので、件数を数えて気づくことが大切です。
LEFT JOIN:片方の表の行をすべて残す
「一度も買っていない会員も含めて、顧客ごとの注文数を出したい」ときは、INNER JOINでは困ります。注文がない顧客は、注文の表に相手がいないため消えてしまうからです。
このようなときはLEFT JOINを使います。LEFT JOINは、FROMに書いた左側の表の行をすべて残し、右側の表に相手がいれば値を付け、いなければNULL(値なし)を入れます。
SELECT c.customer_id, c.channel, o.order_id, o.amount
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
WHERE c.customer_id IN ('C007', 'C008');
| customer_id | channel | order_id | amount |
|---|---|---|---|
| C007 | SNS | O111 | 9800 |
| C008 | 自然検索 | NULL | NULL |
C008 は注文がありませんが、LEFT JOINなので行は残り、注文の列はNULLになりました。

LEFT JOINのあとの数え方に注意
LEFT JOINでつないだあとに顧客ごとの注文数を数えるときは、COUNT(*) と COUNT(o.order_id) の違いに注意します。
SELECT c.customer_id,
COUNT(*) AS wrong_count,
COUNT(o.order_id) AS orders
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
GROUP BY c.customer_id
ORDER BY c.customer_id;
| customer_id | wrong_count | orders |
|---|---|---|
| C001 | 4 | 4 |
| C002 | 3 | 3 |
| C003 | 1 | 1 |
| C004 | 2 | 2 |
| C005 | 1 | 1 |
| C006 | 2 | 2 |
| C007 | 1 | 1 |
| C008 | 1 | 0 |
注文のない C008 は、COUNT(*) だと「1」になってしまいます。LEFT JOINで残った行(注文の列がNULLの行)を1行として数えるからです。第3回で学んだとおり、COUNT(列) はNULLを数えないので、COUNT(o.order_id) なら正しく「0」になります。
流入元ごとの成績を出す
LEFT JOINとGROUP BYを組み合わせると、流入元ごとの顧客数・注文数・売上を、注文がない顧客も含めて出せます。
SELECT c.channel,
COUNT(DISTINCT c.customer_id) AS customers,
COUNT(o.order_id) AS orders,
COALESCE(SUM(o.amount), 0) AS sales
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
GROUP BY c.channel
ORDER BY sales DESC;
| channel | customers | orders | sales |
|---|---|---|---|
| 検索広告 | 2 | 6 | 28800 |
| 自然検索 | 3 | 5 | 14100 |
| メール | 1 | 1 | 12800 |
| SNS | 2 | 2 | 12200 |
COALESCEは「最初のNULLでない値を返す」関数で、売上がNULLになる場合に0を入れるために使っています。検索広告から来た顧客は2人ですが、6件・28,800円と、1人あたりの購入が多いことが分かります。
会員登録はしたが、一度も注文していない顧客を含めて、顧客ごとの注文数を出したいとき、最も適した書き方はどれでしょう?
- FROM orders INNER JOIN customers で、COUNT(*) を数える
- FROM customers INNER JOIN orders で、COUNT(*) を数える
- FROM customers LEFT JOIN orders で、COUNT(o.order_id) を数える
答えと解説
正解は3です。注文のない顧客を残すには、顧客の表を左にしたLEFT JOINを使います。さらに、注文のない行はNULLになるので、COUNT(*) ではなく COUNT(o.order_id) で数えると、正しく0になります。
「相手がいない行」を探す
LEFT JOINのあとに「右側の列がNULLの行」だけを取り出すと、相手がいない行を探せます。これは、データの抜けを見つけたり、施策の対象者を探したりするのに便利です。
一度も注文していない会員(購入を促すメールの対象)を探すなら、次のとおりです。
SELECT c.customer_id, c.prefecture
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL;
| customer_id | prefecture |
|---|---|
| C008 | 北海道 |
逆に、注文の表を左にすると、顧客の表に存在しない顧客の注文(データの不整合)が見つかります。
SELECT o.order_id, o.customer_id, o.amount
FROM orders AS o
LEFT JOIN customers AS c
ON o.customer_id = c.customer_id
WHERE c.customer_id IS NULL;
| order_id | customer_id | amount |
|---|---|---|
| O115 | C009 | 1900 |
先ほどINNER JOINで消えた O115 が見つかりました。分析を始める前に、こうした「相手のいない行」がどれくらいあるかを確かめておくと、あとで数字が合わない原因をすぐに突き止められます。

よくある落とし穴:つなぐと行が「増える」
JOINのもう1つの落とし穴は、行が増えることです。つなぐ相手が複数あると、元の1行が相手の数だけ複製されるからです。
例として、流入元ごとの6月の広告費の表 ad_costs(架空のデータ)を用意しました。
| channel | month | cost |
|---|---|---|
| 検索広告 | 2026-06 | 20000 |
| SNS | 2026-06 | 15000 |
6月の売上と広告費を流入元ごとに並べようとして、注文・顧客・広告費の3つの表をつないでから合計すると、次のようになります。
SELECT c.channel,
SUM(o.amount) AS sales,
SUM(a.cost) AS cost
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
INNER JOIN ad_costs AS a
ON c.channel = a.channel AND a.month = '2026-06'
WHERE o.order_date BETWEEN '2026-06-01' AND '2026-06-30'
GROUP BY c.channel;
| channel | sales | cost |
|---|---|---|
| SNS | 9800 | 15000 |
| 検索広告 | 15500 | 60000 |
検索広告の広告費が60,000円になっています。本当は20,000円です。6月に検索広告の顧客の注文が3件あったため、広告費の1行が注文の3行それぞれにつながり、3回足されてしまったのです。このように、「1対多」でつないだあとに「1」の側の数字を合計すると、多い側の行数だけ水増しされます。

解決策:先にまとめてから、つなぐ
この問題を避けるには、粒度(1行が何を表すか)をそろえてからつなぐのが基本です。売上を先に流入元ごとにまとめて「1流入元=1行」にしてから、同じく「1流入元=1行」の広告費の表とつなぎます。
ここで便利なのがWITH句です。WITH 名前 AS (SQL) と書くと、その結果に名前を付けて、後ろのSQLで表のように使えます。
WITH june_sales AS (
SELECT c.channel, SUM(o.amount) AS sales
FROM orders AS o
INNER JOIN customers AS c
ON o.customer_id = c.customer_id
WHERE o.order_date BETWEEN '2026-06-01' AND '2026-06-30'
GROUP BY c.channel
)
SELECT a.channel,
s.sales,
a.cost,
ROUND(s.sales * 100.0 / a.cost) AS roas_pct
FROM ad_costs AS a
LEFT JOIN june_sales AS s
ON a.channel = s.channel
WHERE a.month = '2026-06';
| channel | sales | cost | roas_pct |
|---|---|---|---|
| 検索広告 | 15500 | 20000 | 78.0 |
| SNS | 9800 | 15000 | 65.0 |
広告費は正しく20,000円になり、売上÷広告費(ROAS)も計算できました。なお、この例は説明のための単純化で、「その流入元から最初に来た顧客の、6月の売上すべて」を広告の成果とみなしています。実際の広告の効果測定では、どの接点に成果を割り当てるか(アトリビューション)を別に考える必要があります。

WITH句は、長いSQLを「部品」に分けて、上から順に読めるようにする道具でもあります。第5回のファネル分析やRFM分析でも、WITH句で段階的に組み立てていきます。
注文の表(1行=1件の注文)と、流入元ごとの月の広告費の表(1行=1流入元・1か月)をそのままつないで SUM(cost) を出したところ、広告費が実際より大きくなりました。最も適切な対処はどれでしょう?
- 注文を先に流入元ごとにまとめてから、広告費の表とつなぐ
- SUM(cost) を AVG(cost) に変えれば、どんな場合も必ず正しくなる
- INNER JOIN を LEFT JOIN に変える
答えと解説
正解は1です。原因は、広告費の1行が注文の行数だけ複製されたことです。粒度(1行が何を表すか)をそろえてからつなぐのが基本です。AVGに変えると偶然合う場合もありますが、仕組みを理解しないまま使うと別の場面で間違えます。
実務ではこう使う
JOINが使えると、別々のシステムに入っているデータを組み合わせた分析ができるようになります。たとえば次のようなものです。
- 広告・SEO・SNSなど流入元ごとの、顧客数・購入数・売上・1人あたり売上
- 会員の属性(業種・地域など)ごとの、よく買われる商品
- 資料請求したが商談になっていないリード、会員登録したが購入していない顧客の一覧
実務でJOINを書くときは、次の4つを確かめると、数字の間違いを大きく減らせます。
- つなぐ前に、両方の表の粒度とキーを確かめる:1行は何か、キーに重複はないか
- JOINの前後で件数を数える:減っていれば「相手のいない行」、増えていれば「1対多の複製」を疑う
- INNERかLEFTかを意識して選ぶ:残したいのはどちらの表の行か
- 合計は元の表と突き合わせる:つないだ後の売上の合計が、元の注文の表の合計と一致するか

顧客のデータを広告・メール・MAなど複数のツールにまたがって統合したい場合は、顧客データ活用とCDP(全6回)や、データをまとめて流し込むETLツールおすすめ比較も参考になります。
FOR BUSINESS
マーケティング・DXの発注先選びに迷ったら
ツギノテを運営する株式会社Abinは、BtoBマーケティングに特化したコンサルティング会社です。創業から30社以上のBtoBマーケティングを支援してきました。
相談無料ツギノテ編集部に相談する→この回のまとめ
- JOINは、共通の列(キー)で2つの表を横につなぐ書き方です。ONにつなぐ条件を書き、表に別名を付けて列を書き分けます。
- INNER JOINは両方の表に相手がいる行だけを残し、相手がいない行は黙って消えます。JOINの前後で件数を確かめます。
- LEFT JOINは左側の表の行をすべて残し、相手がいなければNULLを入れます。数えるときは COUNT(*) ではなく COUNT(右側の列) を使います。
- LEFT JOINのあとに「右側がNULL」の行を取り出すと、未購入の会員やデータの不整合を探せます。
- 1対多でつなぐと「1」の側の数字が複製されて水増しされます。WITH句で先にまとめ、粒度をそろえてからつなぎます。
確認テスト
INNER JOINとLEFT JOINの違いとして正しいものはどれでしょう?
- INNER JOINは左側の表をすべて残し、LEFT JOINは両方にある行だけを残す
- INNER JOINは両方の表に相手がいる行だけを残し、LEFT JOINは左側の表の行をすべて残す
- 2つの違いは、処理の速さだけである
答えと解説
正解は2です。INNER JOINは相手がいない行を消し、LEFT JOINは左側(FROMに書いた表)の行を残して、相手がいない部分をNULLにします。どちらの表の行を残したいかで選びます。
一度も注文していない会員を探したいとき、正しいSQLの組み立て方はどれでしょう?
- FROM customers LEFT JOIN orders でつなぎ、WHERE o.order_id IS NULL で絞る
- FROM customers INNER JOIN orders でつなぎ、WHERE o.order_id IS NULL で絞る
- FROM orders だけで、WHERE customer_id IS NULL で絞る
答えと解説
正解は1です。LEFT JOINで顧客の行をすべて残すと、注文がない顧客は注文の列がNULLになります。それを IS NULL で取り出します。INNER JOINでは、注文のない顧客は最初から消えてしまいます。
LEFT JOINでつないだ結果をGROUP BYして、顧客ごとの注文数を数えるとき、注文のない顧客を正しく0にできる書き方はどれでしょう?
- COUNT(*)
- COUNT(c.customer_id)
- COUNT(o.order_id)
答えと解説
正解は3です。注文のない顧客の行は、注文の列(o.order_id)がNULLになります。COUNT(列) はNULLを数えないので0になります。COUNT(*) や顧客の表の列を数えると1になってしまいます。
WITH句の説明として正しいものはどれでしょう?
- 表を永久に新しく作り、データベースに保存する命令
- SQLの結果に名前を付けて、後ろのSQLで表のように使えるようにする書き方
- JOINの代わりに使う、表をつなぐための命令
答えと解説
正解は2です。WITH句で作った結果は、その1つのSQLの中だけで使える一時的な表のようなものです。先にまとめてからつなぐときや、長いSQLを部品に分けて読みやすくするときに使います。
次回予告
次回は、ここまでの書き方を組み合わせて、マーケティングの代表的な分析をSQLで書きます。商品ページから購入までのファネル、初回購入の月ごとに顧客の継続を追うコホート分析、最新購入日・購入回数・購入金額で顧客を分けるRFM分析を、WITH句で段階的に組み立てます。
参考資料
- Google Cloud「GoogleSQL:Query syntax(JOIN operation)」(確認日:2026年11月12日)https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax
この記事は役に立ちましたか?
NEXTあわせて読みたい
-
BPOサービス(業務アウトソーシング)おすすめ7選|コンタクトセンター・バックオフィスの外注先を規模・料金・事例で比較
-
Shopify制作会社おすすめ7選|Shopify Plus・越境EC・運用まで、認定・実績・事例で比較
-
マーケティング研修おすすめ7選|法人向けの基礎・実践・BtoB研修を料金・形式・実績で比較
-
ECコンサル会社おすすめ5選|楽天・Amazon・自社ECの売上を伸ばす支援会社を範囲・料金・事例で比較
-
広報・PR代行(PR会社)おすすめ7選|ベクトル・プラップジャパン・共同ピーアールなどを支援範囲・料金・事例で比較
-
企業データベースおすすめ6選|Speeda・帝国データバンク・東京商工リサーチ・ユーソナーなどを料金・収録数で比較
