こんにちは!KredoのTakeo Yokotaです。
表紙はこの記事専用に生成した学習イメージです。実在の受講生・授業の写真ではありません。正確な件数や対応関係は本文の表と図で確認します。
SQLで注文と明細をJOINしたら、注文数や合計金額が合わなくなった。エラーは出ないのに数字が違い、DISTINCTを付けても直らない。そんなときは、計算式より先に「結合後の1行は何を表すのか」を確認しましょう。
原因の一つは、1件の注文に複数の明細が対応し、注文の金額が結果の複数行に現れることです。そのまま足すと、同じ注文を何回も加算してしまいます。金額の合計だけが必要なら注文テーブルだけを集計し、明細件数も必要なら、明細を注文単位へ集計してから結合するのが今回の直し方です。
この記事では、注文3件・明細3件の小さな架空データで、正しい合計3,200円が4,400円になってしまう過程をたどります。結合結果と主要な集計結果を本文に載せているので、実行環境がなくても紙に書いて確かめられます。後半には、Python標準ライブラリのsqlite3で動くダウンロード教材と練習問題も用意しました。
Kredoは、英語×IT・AIを通じてグローバルに活躍・就職する人材を育成し、累計3,000名の卒業生を送り出してきました。この記事では、コードの意味を説明し、結果を自分で検算する練習を扱います。SQL教材の作成・実行確認は編集部で行っています。
データベースやMySQLの役割から確認したい方は、PHPとMySQLの入門記事も参照してください。ここではSELECTで表を読み取るところから進みます。
目次
1. JOIN後に合計が増えたら、まず「1行の意味」を確認する
注文と明細は、1行が表すものが違う
架空の注文記録を使います。ordersは「1注文につき1行」、order_itemsは「1明細につき1行」です。この「1行が表す単位」を、データの粒度と呼ぶことがあります。用語を覚えるより、「この行は注文なのか、明細なのか」と言い換えられることが大切です。
注文テーブルordersは次の3行です。amount_yenは、その注文に記録された金額です。会計上の売上や消費税の計算を教える例ではなく、整数の記録値を足す教材です。
| order_id(注文ID) | amount_yen(円) |
|---|---|
| 101 | 1200 |
| 102 | 1200 |
| 103 | 800 |
注文101と102は、たまたまどちらも1,200円ですが、別の注文です。元の合計は1,200+1,200+800=3,200円。注文IDは3種類あります。
明細テーブルorder_itemsは次の3行です。
| item_id(明細ID) | order_id(注文ID) | label(品名) |
|---|---|---|
| 1 | 101 | Notebook |
| 2 | 101 | Pen |
| 3 | 102 | Folder |
注文101には明細1・2が、注文102には明細3が対応します。注文103の明細は、教材ではまだ取り込まれていない設定です。「明細が見つからないから注文額も0円」とは考えません。明細別の金額や数量は、この教材には持たせていません。
主キーがあるのに、なぜ注文IDが繰り返される?
ordersのorder_idは主キーで、注文を一意に識別します。一方、order_itemsのorder_idは、対応する注文を指す外部キーです。1つの注文へ明細が複数つながるので、この列の101が繰り返されること自体は正常です。明細側で一意なのはitem_idです。
主キーが一意でも、JOINの結果にその値が1回だけ出るとは限りません。 JOINは、条件に合う行の組合せを結果へ並べるからです。1注文と複数明細という「1対多」の関係を、次の節で実際に展開してみましょう。
なお、SELECTで結果の行が増えても、保存されているordersやorder_itemsの行が書き換わったわけではありません。SQLiteのSELECT公式資料でも、SELECTはデータベースへ変更を加えず結果を返す文として説明されています。
2. 3件の注文をJOINして、4行になる過程を見よう
JOINの結果を確認するときは、最初からSUMで1つの数字にまとめず、注文IDと明細IDを一緒に表示します。次のSQLを実行してみてください。
SELECT o.order_id, o.amount_yen, i.item_id
FROM orders AS o
LEFT JOIN order_items AS i
ON o.order_id = i.order_id
ORDER BY o.order_id, i.item_id;
FROM orders AS oは「ordersをoという短い名前で読む」、order_items AS iは「明細をiという名前で読む」という意味です。o.order_idのように表の別名を付けると、どちらの列かが明確になります。ONでは、注文IDが一致する行どうしを対応させています。
LEFT JOINは、左側のordersの行を残す結合です。対応する明細があれば、その組合せを返します。明細がなければ、右側の列がNULLの行を返します。ここでのNULLは、対応する明細の値がないことを示し、数字の0とは異なります。ORDER BYは表示順を指定するものです。これがないSELECTの行順を、いつも同じとは考えないようにしましょう。

実行結果は、次の4行です。
| order_id | amount_yen | item_id |
|---|---|---|
| 101 | 1200 | 1 |
| 101 | 1200 | 2 |
| 102 | 1200 | 3 |
| 103 | 800 | NULL |
101の注文金額1,200円が2回現れました。これは、注文101を新しく追加したためではなく、「101と明細1」「101と明細2」という2つの組合せができたためです。結合後の行は、もう「1注文1行」ではありません。
この結果でamount_yenを足すと、1,200+1,200+1,200+800=4,400円です。元の3,200円との差1,200円は、注文101を余分に1回足した金額と一致します。結果の101の2行に印を付けると、どの注文が増加分を作ったのかを説明できます。
INNER JOINに替えても、合計の問題は解決しない
同じSQLのLEFT JOINをINNER JOINへ替えると、一致する明細のない注文103が結果から消えます。残るのは101が2行、102が1行の計3行です。合計は3,600円、注文IDは2種類となります。
元のordersも3行なので、行数だけを見ると正しく見えるかもしれません。しかし101を重ねて数え、103を落としています。行数が同じことは、正しい注文が1回ずつ残った証明にはなりません。 LEFTかINNERかは、「対応のない注文も含めるか」という目的で選び、重複集計の応急処置として替えないことが大切です。
結合と未対応行の基本は、PostgreSQL公式のJOIN入門でも確認できます。本記事の実行検証はSQLiteで行っており、PostgreSQLの実行画面を示したものではありません。
3. COUNTとSUMで何を数えているかを切り分ける
次は4行の結果を、いくつかの集計関数で調べます。COUNTは「数を数える」関数ですが、かっこの中に何を書くかで対象が変わります。
SELECT COUNT(*) AS joined_rows,
COUNT(i.item_id) AS matched_items,
COUNT(DISTINCT o.order_id) AS orders_count,
SUM(o.amount_yen) AS naive_sum
FROM orders AS o
LEFT JOIN order_items AS i
ON o.order_id = i.order_id;

| 式 | 結果 | 今回数えているもの |
|---|---|---|
| COUNT(*) | 4 | 結合後の全行 |
| COUNT(i.item_id) | 3 | NULLでない明細ID |
| COUNT(DISTINCT o.order_id) | 3 | 異なる注文ID |
| SUM(o.amount_yen) | 4400 | 4行に現れた注文金額の合計 |
COUNT(*)は103の未対応行も含めます。COUNT(i.item_id)は、103の行にあるNULLを数えません。この教材ではitem_idがNULLにならない主キーなので、一致した明細の件数として使えます。NULLを許す備考などの列を数えると、「一致したけれど、その値がNULL」という行も除かれるため、同じ意味にはなりません。
COUNT(DISTINCT o.order_id)は101を1種類にまとめます。今回の目的が注文数なら3で正解です。一方、これでSUMの計算対象まで直るわけではありません。それぞれの関数は、指定された値を独立して集計します。SQLiteの集計関数資料も、count(*)とcount(X)、DISTINCTを指定した集計の違いを説明しています。
注文IDごとに「何件と対応したか」を調べる
全体の差が分かったら、どの注文が複数行になったかを絞り込みます。
SELECT o.order_id, COUNT(i.item_id) AS item_count
FROM orders AS o
LEFT JOIN order_items AS i
ON o.order_id = i.order_id
GROUP BY o.order_id
ORDER BY o.order_id;
出力は「101:2件、102:1件、103:0件」です。GROUP BYは、指定した値ごとに行をまとめて集計します。このSQLでは注文IDごとに明細の数を確認しています。103を0件にしたいので、COUNT(*)ではなく明細IDを数えている点も見直してください。
明細が複数あること自体は誤りではありません。想定どおりの1対多なのか、結合条件が足りず別の注文まで一致したのかを区別する材料になります。実務で注文IDが店舗内だけで一意なら、店舗IDも結合条件に必要かもしれません。教材の単一キーを、そのまま実データへ当てはめないようにしましょう。
4. DISTINCTを付けるだけでは直らない理由
DISTINCTは「重複を除く」という言葉から万能に見えますが、何を同じとみなすかが重要です。今回、残したいのは別々の注文101と102です。金額が同じだからといって、片方を消してはいけません。
SELECT SUM(DISTINCT o.amount_yen) AS wrong_sum
FROM orders AS o
LEFT JOIN order_items AS i
ON o.order_id = i.order_id;
この答えは2,000円です。SUM(DISTINCT amount_yen)は、金額の異なる値だけを足すので、1,200と800の2種類になります。101の繰り返しだけでなく、同額の別注文102まで1つにまとめてしまいました。

MySQL 8.4のSUM公式説明でも、DISTINCTを付けたSUMは異なる式の値を足すものとされています。注文IDを暗黙に見てくれる機能ではありません。
SELECT DISTINCTでも、残す列が違えば答えが変わる
SELECT DISTINCT o.order_id, o.amount_yen, i.item_idと書いても、今回の4行は残ります。101の2行はitem_idが1と2で違い、全列が同じ行ではないためです。
一方、注文IDと金額だけを選び、SELECT DISTINCT o.order_id, o.amount_yenとすれば、今回のデータでは3注文の組になります。その結果を外側でSUMする方法も成立します。ただし、後から明細列を追加すれば、再び1注文が複数行になる可能性があります。「DISTINCTは使ってはいけない」ではなく、「どの列の組を1つとみなしたいか」を説明できる状態で使いましょう。
GROUP BYしてからSUMしても、重複した値は足される
結合した後に注文IDでGROUP BYし、SUM(o.amount_yen)を求めると、101は2,400円、102は1,200円、103は800円になります。3行にまとまっても、101の金額は直っていません。GROUP BYは、すでに重複した注文金額を自動で元に戻す仕組みではないからです。
件数で割る、MINやMAXで1つだけ取り出す、LIMITで行を切る、といった処理にも、成り立つ条件が必要です。欠落した注文や結合条件の誤りは隠せません。さらに、正当な明細をDELETEして行数を合わせると、元データを壊してしまいます。今回直すのは読み取りの集計方法であり、元の明細ではありません。
5. 必要な単位に集計してから結合する
修正方法を選ぶ前に、欲しい結果を一文で書きます。「全注文の金額合計だけ」なのか、「注文ごとの金額と明細件数」なのかで、必要なSQLは違います。
金額合計だけなら、JOINしない
ordersに注文金額が1注文1行で記録されているので、今回の全注文合計はこれだけで求まります。
SELECT SUM(amount_yen) AS total_yen
FROM orders;
結果は3,200円です。明細の条件で注文を絞る必要も、明細の情報を表示する必要もないなら、明細テーブルを結合する理由がありません。まず最も単純な正解を残しておくと、複雑な集計結果との比較にも使えます。
明細件数も添えるなら、明細を先に注文単位へまとめる
最初に、明細だけを注文IDで集計します。
SELECT order_id, COUNT(*) AS item_count
FROM order_items
GROUP BY order_id
ORDER BY order_id;
中間結果は次の2行です。
| order_id | item_count |
|---|---|
| 101 | 2 |
| 102 | 1 |
ここでは元のorder_itemsを直接数えているので、COUNT(*)で明細件数になります。注文103の明細行は存在しないため、中間結果にも103は出ません。

このSELECTをかっこで囲み、FROMの中で一時的な表として使います。これをサブクエリ、またはこの位置では派生テーブルと呼びます。見慣れない構文でも、まず「さっきの2行の表をここへ置いた」と読めば大丈夫です。
SELECT o.order_id, o.amount_yen,
COALESCE(i.item_count, 0) AS item_count
FROM orders AS o
LEFT JOIN (
SELECT order_id, COUNT(*) AS item_count
FROM order_items
GROUP BY order_id
) AS i ON o.order_id = i.order_id
ORDER BY o.order_id;
| order_id | amount_yen | item_count |
|---|---|---|
| 101 | 1200 | 2 |
| 102 | 1200 | 1 |
| 103 | 800 | 0 |
101の右側は、明細そのものの2行ではなく、件数2を持つ1行です。そのため注文金額が繰り返されません。結果は3行、注文金額の合計は3,200円、明細件数の合計は3です。
COALESCE(i.item_count, 0)は、対応する集計行がなくNULLになった件数を0で表示しています。「この明細テーブルに該当行が0件」という意味であり、注文が無効・未購入という意味ではありません。NULLなら何でも0円にしてよい、という一般ルールでもありません。
最後の合計も、整えた結果から取る
明細件数と注文金額の全体合計を一緒に表示したい場合は、前集計した表との結合から計算できます。
SELECT SUM(o.amount_yen) AS total_yen,
SUM(COALESCE(i.item_count, 0)) AS total_items
FROM orders AS o
LEFT JOIN (
SELECT order_id, COUNT(*) AS item_count
FROM order_items
GROUP BY order_id
) AS i ON o.order_id = i.order_id;
結果はtotal_yenが3200、total_itemsが3です。右側の集計結果が注文IDにつき最大1行だからこそ、このSUMは注文金額を1回ずつ足せます。別の明細表をこの後にそのまま追加結合すれば、また複数行になることもあります。修正後も、出力の1行の意味を確認する習慣は残してください。
6. 自分で直す練習と、実務データに戻す前の確認
説明を読んで分かったつもりになったら、今度は結果を見る前に予想を書いてみましょう。「元データ」「予想」「実測」「理由」の4欄を埋めると、数字を偶然当てただけなのか、結合の仕組みを説明できるのかが分かります。

教材の実行方法
SQL JOIN実習パックをダウンロードする。架空データのsetup.sql、確認用queries.sql、問題exercise.sql、answers.md、期待値CSV、実行スクリプトrun_practice.py、READMEをまとめています。外部サービスへの登録や会社のアカウントは不要です。
- ZIPを展開し、READMEを読みます。SQLやスクリプトは、知らないものをそのまま実行せず内容を確認してください。
- Python 3を使える端末で、展開したフォルダーをターミナルで開きます。
- 下のコマンドを実行します。環境によってpython3ではなくpythonまたはpy -3を使います。
- 表示された結果を本文とexpected-results.csvで照合し、なぜ違う数字になるかを説明します。
python3 run_practice.py
付属スクリプトはPython標準のsqlite3を使い、メモリ内に練習専用データベースを作ります。会社のデータベースへ接続せず、外部への通信も行いません。2026年10月8日(日本時間)にPython 3.12.14/SQLite 3.53.1で検証しました。MySQLやPostgreSQLのサーバーでは実行していません。Pythonが使えない場合は、本文の表を紙に転記して各問題を解いても、同じ考え方を練習できます。
問題1:COUNTの4と3を説明しよう
第2節の4行を見ながら、次の2点を自分の言葉で書いてください。
- なぜ101は2回現れるのでしょうか。
- なぜCOUNT(*)は4なのに、COUNT(i.item_id)は3なのでしょうか。
解答:101には明細1と2が対応し、それぞれとの組合せが1行ずつ出るからです。COUNT(*)は103の未対応行も含む4行を数えます。COUNT(i.item_id)は103のNULLを除くため3になります。「SQLが勝手に注文を増やした」「COUNTが不正確」という説明では、結合結果の単位を捉えられていません。
問題2:2,000円になったSQLを直そう
第4節のSUM(DISTINCT o.amount_yen)を、次の2つの目的ごとに直してください。
- A:全注文の金額合計だけを知りたい
- B:注文ごとの金額と明細件数を並べたい
解答A:第5節の、ordersだけをSUMするSQLです。結果は3,200円になります。解答B:明細をorder_idで先にCOUNTし、その結果をordersへLEFT JOINするSQLです。結果は101が「1200・2」、102が「1200・1」、103が「800・0」です。
DISTINCTを外すだけでは4,400円へ戻ります。INNER JOINへ替えると103が消え、金額は3,600円になります。「3,200に近いか」ではなく、「全注文が1回ずつ含まれているか」で解答を評価しましょう。
問題3:注文104を追加したら、どこが変わる?
新しい注文104・600円と、それに対応する明細2件を追加します。初期データへ一度だけ追加するSQLです。教材は毎回新しいメモリ内データベースを作るため、再実行で同じIDを重ねて登録しません。手作業で同じDBへ繰り返すと、主キーの重複エラーになります。
INSERT INTO orders (order_id, amount_yen)
VALUES (104, 600);
INSERT INTO order_items (item_id, order_id, label)
VALUES (4, 104, 'Marker'), (5, 104, 'Paper');
先に、注文数、正しい金額合計、明細数、素のLEFT JOINの行数とSUMを予想してください。追加ケースは、次のコマンドで実行します。初期データから作り直して注文104を一度だけ追加するため、先にINSERTを手動実行する必要はありません。
python3 run_practice.py --scenario added104

| 確認対象 | 追加後の正解 |
|---|---|
| 元の注文件数 | 4 |
| 元の注文金額合計 | 3800円 |
| 明細件数 | 5 |
| 素のLEFT JOINの行数 | 6 |
| 素のLEFT JOIN後のSUM | 5600円 |
| SUM(DISTINCT amount_yen) | 2600円 |
| 前集計してから結合した行数 | 4 |
| 前集計してから結合した金額合計 | 3800円 |
101と104がそれぞれ2行になり、102と103が1行ずつなので、素の結合は6行です。金額は101の1,200円と104の600円が1回ずつ余分に足され、3,800+1,200+600=5,600円になります。DISTINCTなら金額の種類1,200・800・600だけを足して2,600円です。
前集計後の結果へ増えるのは「104、600、2」という1行です。4注文の金額を1回ずつ足せば3,800円になります。数字を替えても同じ説明ができることが、この練習の到達点です。
実務のSQLへ戻す前のチェック
- 元の表と出力の「1行の意味」をそれぞれ書けるか
- キーが本当に一意か。会社・店舗・日付などを含む複合キーではないか
- 対象期間、キャンセル、対象注文の条件が比較元と一致しているか
- 未対応の注文を残す必要があるか。LEFT JOIN後に右表の列をWHEREで絞り、未対応行を落としていないか
- 注文IDごとの対応件数と、残ったID・消えたIDを確認したか
- 合計が一致するだけでなく、同額の別注文や明細0件のケースも確認したか
- 元データの重複や取込漏れが疑わしいとき、仕様と担当者へ確認したか
今回の教材は、1注文に複数明細がある場面へ絞っています。多対多の関係、複数の明細表を同時に結ぶケース、性能改善、実際の売上認識や税計算は扱っていません。実務の数値を報告する前には、集計の対象と定義を担当者とそろえましょう。
よくある疑問
JOINしただけで元データが増えたのですか?
ここで使ったSELECTでは元テーブルを変更していません。増えたのは、その条件で返された結果の行です。INSERTなどの書き込み処理とは区別します。
LEFT JOINなら左の行数と同じになるのでは?
左の行が残ることと、1行だけになることは別です。右側で2行が一致すれば、その左行は2組になります。右側を結合キーにつき最大1行へ整えると、今回のように左の3行を保てます。
INNER JOINに替えれば直りますか?
一致しない注文が落ちるだけで、1対多は残ります。今回の3行・3,600円は、101の重複と103の欠落が同時に起きた結果です。
MySQLでも同じ考え方が使えますか?
1対多で結果が複数行になること、COUNTの対象、SUM(DISTINCT)が金額の値をまとめることは、MySQLの公式資料でも確認できます。ただし、同梱の実行スクリプトとスキーマ検証はSQLite用です。MySQLでの実行確認済みとはしていません。型や制約、接続方法などは利用するバージョンで確認してください。
最後に、困ったら「SUMを直す前に、注文IDと明細IDを並べる」ことを思い出してください。1行の意味と対応関係が見えれば、JOINが必要か、先に集計すべきかを選びやすくなります。
単一ファイルから集計の考え方を練習したい方はPythonでCSVを集計する実習、実資料を使わず別の題材を作りたい方はAIで架空の業務データを作る練習へ進んでみてください。
参考資料(2026年10月8日・日本時間確認)
- SQLite:SELECT — 結合、LEFT JOIN、表示順、DISTINCT
- SQLite:集計関数 — COUNT、SUM、DISTINCT
- MySQL 8.4:JOIN — 結合条件と未対応行
- MySQL 8.4:集計関数 — SUM(DISTINCT)など
- MySQL 8.4:派生テーブル — FROM内のサブクエリ
- PostgreSQL:JOIN入門 — 表の結合と外部結合
数値、注文・品名、図解は本記事用の架空教材です。実行結果はこの教材の出力であり、実際の売上や学習者の成果ではありません。
英語でIT・AIを学べるKredo
英語×IT・AIのスキルを身につけてグローバルに活躍しませんか?
当メディアを運営しているKredoは、英語×ITをオンラインで学ぶ「Kredoオンラインキャンプ」と、フィリピンのセブ島で英語とIT・AIを学ぶ「KredoIT留学」「KredoAI留学」を提供しています。これまでの卒業生は3,000名以上。卒業生の多くが、国内外のIT企業への転職、フリーランスなどへのキャリアチェンジを実現しています。これからの時代に必要な英語×IT・AIのスキルを身につけてグローバルに活躍しませんか?









