SQL の JOIN・GROUP BY・サブクエリ:集計がずれる罠と実測

SQL講座3。SQLite 3.51 で INNER/LEFT JOIN、合計を3倍にする行の爆発、HAVING と WHERE、COUNT(*) と COUNT(列)、サブクエリ対 JOIN の速度を実測。

シリーズ:SQL 講座 3/6(中級)。 1つのテーブルへのクエリは書けるようになり(第1・2回)、次は「顧客別の売上」「一度も注文していない顧客」のように複数テーブルにまたがる答えが必要な人向けです。読み終えると、適切な JOIN を選び、グループ化と絞り込みを正しく行い、レポートの数字を静かに狂わせる3つのミスを見抜けるようになります。

この回のクエリはエラーになりません。もっともらしいけれど間違った数字を返すだけです。ちょうど3倍に膨らんだ顧客別合計、注文件数と同じになる「都市別の顧客数」、見たかった顧客をこっそり落とす LEFT JOIN。月末に営業部から「ダッシュボードの数字が Excel と合わない」と言われたとき、原因はたいていこのどれかです。手で数えられるほど小さなデータで1つずつ再現しました。すべて Apple Silicon Mac(M1 Pro)の sqlite3 3.51.0 で実行した実際の出力です。

題材データ:顧客・注文・支払い

テーブルは3つです。顧客1人に注文が0件以上、支払いが0件以上あります(1対多の関係)。わざと扱いにくい行を2つ入れました。Dan には注文がなく、注文107には顧客がいません(customer_id が NULL のゲスト購入)。

CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT);
CREATE TABLE orders (
  id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES customers(id),
  amount INTEGER NOT NULL, ordered_on TEXT NOT NULL
);
CREATE TABLE payments (
  id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES customers(id),
  paid INTEGER NOT NULL
);
INSERT INTO customers (id, name, city) VALUES
  (1, 'Aoi', 'Osaka'), (2, 'Ben', 'Seoul'), (3, 'Chloe', 'Osaka'), (4, 'Dan', NULL);
INSERT INTO orders (id, customer_id, amount, ordered_on) VALUES
  (101, 1, 3000, '2026-10-01'), (102, 1, 1200, '2026-10-15'),
  (103, 2,  800, '2026-10-20'), (104, 3, 5000, '2026-11-02'),
  (105, 3,  700, '2026-11-03'), (106, 3, 2500, '2026-11-05'),
  (107, NULL, 999, '2026-11-06');
INSERT INTO payments (customer_id, paid) VALUES (1, 2000), (1, 1000), (1, 1200), (3, 8200);

sqlite3 shop.db < shop.sql で作り、シェルで .headers on、.mode table、.nullvalue NULL をオンにします。

INNER JOIN と LEFT JOIN

JOIN は ON の条件で2つのテーブルの行を組み合わせます。JOIN(INNER JOIN の略)は一致した組だけを残し、LEFT JOIN は左テーブルの全行を残して、相手がいなければ右側を NULL で埋めます。

SELECT c.name, o.id AS order_id, o.amount
FROM customers AS c JOIN orders AS o ON o.customer_id = c.id;
+-------+----------+--------+
| name  | order_id | amount |
+-------+----------+--------+
| Aoi   | 101      | 3000   |
| Aoi   | 102      | 1200   |
| Ben   | 103      | 800    |
| Chloe | 104      | 5000   |
| Chloe | 105      | 700    |
| Chloe | 106      | 2500   |
+-------+----------+--------+

LEFT JOIN にすると7行でした。同じ6行に Dan | NULL | NULL が加わります。注文107は組む顧客がいないので、どちらにも出てきません。SQLite 3.39.0 以降は RIGHT JOIN と FULL JOIN も使え、FULL JOIN は 107 | 999 | NULL を含む8行を返しました。両側の「相手のいないデータ」をまとめて探すのに便利です。

LEFT JOIN の結果には注目すべき点があります。Aoi の行が 102, 101、Chloe の行が 105, 106, 104 の順で返ってきました。EXPLAIN QUERY PLAN が理由を教えてくれます。

QUERY PLAN
|--SCAN c
|--BLOOM FILTER ON o (customer_id=?)
`--SEARCH o USING AUTOMATIC COVERING INDEX (customer_id=?) LEFT-JOIN

SQLite がこのクエリのためだけに orders.customer_id に一時インデックスを作り、そのインデックス順に注文を読んだのです。INNER JOIN のほうはたまたま挿入順に読んだだけです。どちらも約束ではありません。ORDER BY がなければ順序は実行計画しだいで、データが増えれば計画も変わります。

GROUP BY・HAVING と3種類の COUNT

GROUP BY は同じ値を持つ行をグループごとに1行にまとめ、集約関数(count、sum、avg、min、max)がグループごとに要約します。注文のない顧客も含めた顧客別の注文:

SELECT c.name, count(*) AS rows_, count(o.id) AS orders_, coalesce(sum(o.amount), 0) AS total
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id ORDER BY total DESC;
+-------+-------+---------+-------+
| name  | rows_ | orders_ | total |
+-------+-------+---------+-------+
| Chloe | 3     | 3       | 8200  |
| Aoi   | 2     | 2       | 4200  |
| Ben   | 1     | 1       | 800   |
| Dan   | 1     | 0       | 0     |
+-------+-------+---------+-------+

Dan に注目してください。count(*) は1、count(o.id) は0です。count(*) は行を数え、LEFT JOIN が Dan のために(NULL だらけの)1行を作ったからです。count(列) は NULL でない値だけを数えます。orders テーブル単体では count(*) = 7、count(customer_id) = 6(注文107が NULL)、count(DISTINCT customer_id) = 3 でした。

WHERE はグループ化前の行を、HAVING はグループ化後のグループを絞り込みます。答えが変わります。

クエリ 結果
… GROUP BY c.id HAVING sum(o.amount) >= 1000 Chloe 8200、Aoi 4200(Ben のグループ800が落ちる)
… WHERE o.amount >= 1000 GROUP BY c.id Chloe 7500、Aoi 4200(Chloe の700円の注文と Ben の800円の注文が先に落ちる)

集約関数を WHERE に書くと SQLite は拒否します。

Parse error near line 16: misuse of aggregate: count()
  ers c JOIN orders o ON o.customer_id = c.id WHERE count(o.id) > 1 GROUP BY c.i
                                      error here ---^

よくある間違い:LEFT JOIN+WHERE が黙って INNER JOIN になる

目標:全顧客の11月の注文数、0件も含めて。自然に思いつく最初の書き方:

SELECT c.name, count(o.id) AS nov_orders
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.ordered_on >= '2026-11-01'
GROUP BY c.id ORDER BY c.id;
+-------+------------+
| name  | nov_orders |
+-------+------------+
| Chloe | 3          |
+-------+------------+

実際に起きたこと: LEFT JOIN は Aoi・Ben・Dan をちゃんと残しましたが、WHERE は JOIN の後に実行されます。Dan の o.ordered_on は NULL で、NULL >= '2026-11-01' は真にならないので消されます。Aoi と Ben は注文がすべて10月なので、やはり消されます。直し方: 条件を ON に移し、どの顧客が残るかではなく、どの注文をくっつけるかを決めさせます。

SELECT c.name, count(o.id) AS nov_orders
FROM customers c LEFT JOIN orders o
  ON o.customer_id = c.id AND o.ordered_on >= '2026-11-01'
GROUP BY c.id ORDER BY c.id;
+-------+------------+
| name  | nov_orders |
+-------+------------+
| Aoi   | 0          |
| Ben   | 0          |
| Chloe | 3          |
| Dan   | 0          |
+-------+------------+

ルール:LEFT JOIN では、右側テーブルの条件は ON に、左側テーブルの条件は WHERE に。

よくある間違い:1対多を2つつなぐと行が爆発する

目標:顧客ごとの注文合計と支払い合計。Aoi は4,200円分注文し、4,200円を支払いました。それらしいクエリ:

SELECT c.name, sum(o.amount) AS ordered, sum(p.paid) AS paid
FROM customers c
JOIN orders o   ON o.customer_id = c.id
JOIN payments p ON p.customer_id = c.id
GROUP BY c.id;
+-------+---------+-------+
| name  | ordered | paid  |
+-------+---------+-------+
| Aoi   | 12600   | 8400  |
| Chloe | 8200    | 24600 |
+-------+---------+-------+

Aoi の注文は3倍、支払いは2倍、Chloe の支払いは3倍になり、Ben は支払いがないので消えました。GROUP BY を外すと理由が見えます。Aoi の注文2件×支払い3件が6行になっていました。

+------+----------+--------+------+
| name | order_id | amount | paid |
+------+----------+--------+------+
| Aoi  | 101      | 3000   | 1000 |
| Aoi  | 101      | 3000   | 1200 |
| Aoi  | 101      | 3000   | 2000 |
| Aoi  | 102      | 1200   | 1000 |
| Aoi  | 102      | 1200   | 1200 |
| Aoi  | 102      | 1200   | 2000 |
+------+----------+--------+------+
図。1人の顧客に注文行が2件(3000と1200)、支払い行が3件(2000、1000、1200)ある。両方を customer_id で JOIN すると2×3で6行になり、注文額の合計は4,200ではなく12,600になる。もう一方の経路では CTE で先にテーブルごとに集計し、顧客1人につき1行、注文4,200・支払い4,200の正しい合計になる。
同じ顧客に1対多のテーブルを2つ JOIN すると行が掛け算になる。注文2件×支払い3件=6行で、注文合計は3倍に膨らむ。先にテーブルごとに集計してから JOIN する。

注文と支払いは互いに関係がなく、顧客とだけ関係しているので、JOIN するとすべての組み合わせができてしまいます。直し方: 子テーブルごとに別々に集計し、顧客1人1行同士を JOIN します。

WITH o AS (SELECT customer_id, sum(amount) AS ordered FROM orders GROUP BY customer_id),
     p AS (SELECT customer_id, sum(paid)   AS paid    FROM payments GROUP BY customer_id)
SELECT c.name, o.ordered, p.paid
FROM customers c
LEFT JOIN o ON o.customer_id = c.id
LEFT JOIN p ON p.customer_id = c.id
ORDER BY c.id;
+-------+---------+------+
| name  | ordered | paid |
+-------+---------+------+
| Aoi   | 4200    | 4200 |
| Ben   | 800     | NULL |
| Chloe | 8200    | 8200 |
| Dan   | NULL    | NULL |
+-------+---------+------+

(WITH は名前付きのサブクエリ、つまり CTE です。第6回で詳しく扱います。)スカラーサブクエリ2つ (SELECT sum(amount) FROM orders o WHERE o.customer_id = c.id) でもまったく同じ表になりました。レポートを作るときの簡単なセルフチェック:JOIN した行の count(*) を予想と比べること。いちばん大きいテーブルの行数より多ければ、どこかで爆発しています。

同じ爆発が「都市別の顧客数」にも潜んでいます。customers LEFT JOIN orders に count(*) を使うと Osaka が 5(実は注文件数)で、count(DISTINCT c.id) にすると正しい2になりました。

GROUP BY のエラーと静かな落とし穴

最初の週に出会うエラー:

Parse error near line 17: ambiguous column name: id
  SELECT id, name FROM customers c JOIN orders o ON o.customer_id = c.id;
         ^--- error here

両方のテーブルに id があるので、c.id か o.id と所属を書きます。テーブルが2つ以上になったら、常に別名を付ける習慣にしましょう。

静かなほう:SQLite は、グループ化も集約もしていない列(bare column)を SELECT しても受け付けます。

SELECT c.name, o.ordered_on, sum(o.amount)
FROM customers c JOIN orders o ON o.customer_id = c.id GROUP BY c.id;
+-------+------------+---------------+
| name  | ordered_on | sum(o.amount) |
+-------+------------+---------------+
| Aoi   | 2026-10-01 | 4200          |
| Ben   | 2026-10-20 | 800           |
| Chloe | 2026-11-02 | 8200          |
+-------+------------+---------------+

Chloe の3つの日付のうち、これはどれでしょう。SQLite がたまたま見た行の値で、意味はありません。PostgreSQL はこのクエリを拒否し、既定の ONLY_FULL_GROUP_BY が有効な MySQL も拒否します(どちらもここでは未実行)。だから SQLite で動いていたコードが、ほかの DB に移すと壊れます。文書化された SQLite の例外が1つあります。max() か min() がちょうど1つだけなら、bare column はその最大値・最小値を持つ行から取られます。SELECT c.name, o.id, max(o.amount) … GROUP BY c.id は Aoi | 101 | 3000、Ben | 103 | 800、Chloe | 104 | 5000 と正確でしたが、SQLite 専用です。移植性のあるコードはウィンドウ関数(第6回)かサブクエリを使います。

サブクエリ vs JOIN vs EXISTS

サブクエリはほかの文の中の SELECT です。3つの問いに3つの道具。

「2,500円以上の注文が1件でもある顧客」

SELECT name FROM customers WHERE id IN (SELECT customer_id FROM orders WHERE amount >= 2500);
-- Aoi, Chloe
SELECT c.name FROM customers c JOIN orders o ON o.customer_id = c.id WHERE o.amount >= 2500;
-- Aoi, Chloe, Chloe

JOIN は Chloe を2回返しました(該当する注文が2件)。IN や EXISTS は「1件でもあるか」に重複なしで答え、JOIN は「一致する組を列挙」します。

「一度も注文していない顧客」 ここで NOT IN が牙をむきます。注文107の customer_id が NULL だからです。

クエリ 結果
WHERE id NOT IN (SELECT customer_id FROM orders) 0件
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) Dan
WHERE id NOT IN (SELECT customer_id FROM orders WHERE customer_id IS NOT NULL) Dan

第1回と同じ NULL の論理です。4 NOT IN (1, 2, 3, NULL) は真ではなく NULL です。NULL を気にしなくてよい NOT EXISTS を基本にしましょう。

やりたいこと 使うもの 注意点
両テーブルの列をまとめて JOIN 右側に一致が複数あると重複
相手のいない行も残す LEFT JOIN 右テーブルの条件は ON に
「1件でもある」 EXISTS / IN 特になし、重複なし
「1件もない」 NOT EXISTS NOT IN はサブクエリに NULL があると0件
子テーブル2つの合計 それぞれ先に集計(CTE)してから JOIN 直接 JOIN すると合計が掛け算になる
行ごとに値を1つ SELECT 内のスカラーサブクエリ 内側の列にインデックスが必要(下の実測)

実測:相関サブクエリ vs JOIN、インデックスの有無

「サブクエリは遅い」は半分だけ正しい話です。顧客2,000件と注文200,000件を作り(顧客番号は固定の式 (i * 7919) % 2000 + 1、乱数なし)、顧客別の注文合計を3通りの方法で求めました。3つが同じ行を返すことも確認しています。Python の sqlite3 モジュール(SQLite 3.51.0)で M1 Pro 上で測り、インデックスなしは3回、ありは7回の中央値です。

-- A:相関サブクエリ
SELECT c.id, (SELECT sum(o.amount) FROM orders o WHERE o.customer_id = c.id) FROM customers c ORDER BY c.id;
-- B:LEFT JOIN + GROUP BY
SELECT c.id, sum(o.amount) FROM customers c LEFT JOIN orders o ON o.customer_id = c.id GROUP BY c.id ORDER BY c.id;
-- C:先に集計してから JOIN
SELECT c.id, t.total FROM customers c
LEFT JOIN (SELECT customer_id, sum(amount) AS total FROM orders GROUP BY customer_id) t
  ON t.customer_id = c.id ORDER BY c.id;
A:相関 B:JOIN + GROUP BY C:先に集計して JOIN
インデックスなし(1回目) 13,389.6 ms 183.5 ms 117.4 ms
インデックスなし(2回目) 12,649.4 ms 120.7 ms 42.2 ms
CREATE INDEX ON orders(customer_id)(2回目) 88.9 ms 93.4 ms 86.1 ms
棒グラフ。インデックスなし:相関サブクエリ12,649.4ミリ秒、LEFT JOIN+GROUP BY 120.7、先に集計してから JOIN 42.2。orders の customer_id にインデックスあり:相関サブクエリ88.9、LEFT JOIN+GROUP BY 93.4、先に集計してから JOIN 86.1ミリ秒。
顧客別の注文合計(顧客2,000件・注文200,000件、2回目の中央値、SQLite 3.51.0、Apple M1 Pro)。遅いのはインデックスなしの相関サブクエリだけで、インデックスがあれば3つの書き方の差は約10%以内。

インデックスなしの A の実行計画は CORRELATED SCALAR SUBQUERY 1 → SCAN o でした。顧客2,000人ごとに注文20万件を全部読むので、行の訪問は約4億回です。B と C はインデックスなしでも速かったのですが、それは上の計画で見たとおり SQLite が JOIN のために AUTOMATIC COVERING INDEX を作ったからで、相関サブクエリにはそうしてくれませんでした。本物のインデックスを作ると計画は SEARCH o USING INDEX idx_orders_customer (customer_id=?) に変わり、3つの書き方の差は約10%以内に収まりました。

つまり教訓は「サブクエリを使うな」ではなく、「実行計画を読み、行ごとに繰り返される部分の中に SCAN がないか探せ」です。小さい数字(B と C)は2回の計測の間で最大3倍近くぶれましたが、A の100倍の差はぶれませんでした。

正直な限界とバージョンメモ

  • 実行したのは SQLite 3.51.0 だけです(CLI と Python 3.9 の sqlite3 モジュール、どちらも 3.51.0)。SQLite のリリースノートでは RIGHT・FULL OUTER JOIN が 3.39.0、CTE(WITH)が 3.8.3 で追加されています。計画に出てくる BLOOM FILTER は比較的新しい最適化で、3.51 でのみ確認しました。
  • 自動インデックスは SQLite の機能です(既定で有効、PRAGMA automatic_index)。MySQL や PostgreSQL はハッシュ結合など別の戦略を使うので、B と C の比較は変わる可能性があります(ここでは未実行)。
  • bare column:PostgreSQL はエラー、MySQL は ONLY_FULL_GROUP_BY(5.7 から既定)でエラー、SQLite は許可(実行したのは SQLite のみ)。
  • 時間はノート PC 1台、データがすべてキャッシュに載った状態の値です。相関サブクエリのコストは顧客数×注文数に比例するので、データが10倍ならインデックスなしでおよそ100倍を見込んでください。

練習問題

  1. Osaka の顧客のうち、注文合計が5,000円以上の顧客を挙げてください。
  2. 都市ごとの顧客数と注文数を1つのクエリで出してください。(注意:素直な COUNT の片方は間違えます。)

解答

1.

SELECT c.name, sum(o.amount) AS total
FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE c.city = 'Osaka'
GROUP BY c.id HAVING total >= 5000;

実際の出力:Chloe | 8200。都市の条件は行の条件(WHERE)、合計の条件はグループの条件(HAVING)です。SQLite は HAVING で別名 total を使えますが、PostgreSQL では使えません(sum(o.amount) と書く。ここでは未実行)。

2.

SELECT c.city, count(DISTINCT c.id) AS customers, count(o.id) AS orders
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.city ORDER BY c.city;

出力:NULL | 1 | 0、Osaka | 2 | 5、Seoul | 1 | 1。count(DISTINCT c.id) の代わりに count(*) を使うと Osaka の顧客が5人になりました。JOIN が注文ごとに1行を作ったからです。注文107(顧客なし)は customers 起点の LEFT JOIN なので、どの都市にも入りません。

次回

第4回は速度です。EXPLAIN QUERY PLAN を深く読み、100万行のテーブルをインデックスの有無で測り、複合インデックスの列順と、作ったインデックスが無視されるケースを扱います。

よくある質問

JOIN を1つ足したら SUM の合計が2倍・3倍になったのはなぜ?

同じ親に1対多のテーブルを2つ(例:注文と支払い)まとめて JOIN したからです。注文の各行に支払いの全行が組み合わされ、金額が支払い件数の分だけ重複して足されます。実験では注文2件・支払い3件の顧客の注文合計が4,200ではなく12,600になりました。テーブルごとに先に集計(サブクエリか CTE)してから合計同士を JOIN します。

LEFT JOIN なのに INNER JOIN のように行が消えるのはなぜ?

右側テーブルの条件を WHERE に書くと、右側が NULL の行、つまり LEFT JOIN がわざわざ残した行が消えます。条件を ON 句に移します。11月の注文数のクエリは、条件が WHERE にあると1人、ON にあると4人(うち3人は0件)でした。

サブクエリは JOIN より遅い?

それ自体が遅いわけではありません。内側の列にインデックスのない相関サブクエリは、顧客2,000件・注文20万件で12.6秒、LEFT JOIN+GROUP BY は121ms でした。orders(customer_id) にインデックスを作ると、3つの書き方すべてが86〜93ms でした。EXPLAIN QUERY PLAN で相関サブクエリ内の SCAN を確認します。

  • #SQL
  • #中級
  • #SQLite
  • #JOIN
  • #GROUP BY
  • #サブクエリ

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑