複数列 DISTINCT で組み合わせの種類を調べる — 列が増えると結果はどう変わるか
SELECT DISTINCT user_id のように1列へ DISTINCT を適用すると、その列だけで重複を判定します。SELECT に複数の列を並べると、DISTINCT はすべての列を1つのキーとしてまとめて重複を判定します。
SELECT DISTINCT user_id -- user_id だけで判定 SELECT DISTINCT user_id, category -- (user_id, category) の組で判定 SELECT DISTINCT user_id, category, dt -- 3列の組で判定(さらに緩く)
purchase_logs には同じユーザーが同じカテゴリを複数回購入した記録があります。「各ユーザーがこれまでに購入したことのあるカテゴリの組み合わせ」を重複なく取得してください。
| order_id | user_id | category | amount |
|---|---|---|---|
| 1 | 101 | Books | 1500 |
| 2 | 101 | Electronics | 3000 |
| 3 | 102 | Books | 800 |
| 4 | 101 | Books | 2000 |
| 5 | 102 | Electronics | 5000 |
| 6 | 103 | Books | 1200 |
| 7 | 103 | Books | 900 |
| user_id | category |
|---|---|
| 101 | Books |
| 101 | Electronics |
| 102 | Books |
| 102 | Electronics |
| 103 | Books |
SELECT DISTINCT -- 2列の組で重複を除去 user_id, category FROM purchase_logs ORDER BY user_id, category; /* 実行順序: 1. FROM purchase_logs → 行を読み込む 2. SELECT user_id, category → 2列を射影 3. DISTINCT → 組で重複を除去 4. ORDER BY user_id, category → 並べ替えて出力 */
LEGEND
① 元データ
FROM purchase_logspurchase_logs テーブル(7行)を読み込みます。user_id と category の両列を見ると、(101, Books) が2回(order_id=1,4)、(103, Books) が2回(order_id=6,7)と重複しています。| order_id | user_id | category | amount |
|---|---|---|---|
| 1 | 101 | Books | 1500 |
| 2 | 101 | Electronics | 3000 |
| 3 | 102 | Books | 800 |
| 4 | 101 | Books | 2000 |
| 5 | 102 | Electronics | 5000 |
| 6 | 103 | Books | 1200 |
| 7 | 103 | Books | 900 |
DISTINCT(2列): 7行 → 5種類
DISTINCT user_id, category なら (101, Books) / (101, Electronics) / (102, Books) … のように、ペアが一致しない限り別の行として残ります。このため、列を追加するほどユニークの基準が緩くなり結果行数が増える傾向があります。SELECT DISTINCT user_id は3種類(101/102/103)しか返しませんが、SELECT DISTINCT user_id, category は5種類を返します。同じ user_id でも category が違えば別行として残るためです。何を「ユニーク」とみなしたいかを先に決め、その列だけを SELECT するのが正確な結果を得るコツです。SELECT DISTINCT order_id, user_id, category のように主キー相当の一意な列(order_id)を含めると、すべての行が「組み合わせとして異なる」ためDISTINCT がまったく効きません。重複を消したい場合は判定したいキー列だけを SELECT するのが鉄則です。残したい他の情報が必要ならサブクエリや JOIN を使います。SELECT DISTINCT(user_id), category と書いても括弧は単なるグループ化として扱われ、結局 (user_id, category) の組 に DISTINCT が効きます。意図せず2列判定になり想定より行数が増えてしまいます。DISTINCT はキーワードであり、直後に括弧を付ける書き方は避けましょう。差集合で「片方だけ」のデータを得る — EXCEPT で差分を検出
UNION は2つのリストを「足す」集合演算です。EXCEPT(Oracle では MINUS)は「引く」集合演算で、左の SELECT 結果から右の SELECT 結果に含まれる行を除去します。
SELECT email FROM A EXCEPT -- A に存在して B に存在しない行だけ残る(差集合 A − B) SELECT email FROM B;
A EXCEPT B と B EXCEPT A は異なる結果になります。UNION と違い、EXCEPT は非可換です。どちらから引くかを常に意識してください。また EXCEPT も内部で重複除去が行われます(A 側に同じ値が2件あっても1件になる)。all_subscribers には全メルマガ登録者が、unsubscribed には配信停止リストが格納されています。配信停止していない「現在の有効な登録者」のメールアドレス一覧を取得してください。
| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| yamada@ex.com |
| sato@ex.com |
| yamada@ex.com |
| suzuki@ex.com |
| tanaka@ex.com |
SELECT email FROM all_subscribers EXCEPT -- 左から右を引く(差集合 A − B) SELECT email FROM unsubscribed ORDER BY email; /* 実行順序: 1. SELECT email FROM all_subscribers → 上側の4行を取得 2. SELECT email FROM unsubscribed → 下側の2行を取得 3. EXCEPT → 上から下に含まれる行を除去(差集合: A − B) 4. ORDER BY email → email 昇順で並べ替え */
LEGEND
① 上のリスト — all_subscribers
SELECT email FROM all_subscribers全メルマガ登録者のリスト(4行)です。ここから配信停止リストに含まれる行を EXCEPT で除去します。| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| yamada@ex.com |
all_subs(4) EXCEPT unsubscribed(2) = 2件
EXCEPT ALL を使います(PostgreSQL 対応)。WHERE email NOT IN (SELECT email FROM unsubscribed) でも得られますが、EXCEPT の方が意図が明確で読みやすいです。ただし NOT IN はNULL に注意が必要(サブクエリに NULL が含まれると全行が除外されるバグが起きやすい)。EXCEPT は NULL を含む行も正しく処理するため、差分検出には EXCEPT の方が安全です。all_subscribers EXCEPT unsubscribed(有効な登録者を取得)と unsubscribed EXCEPT all_subscribers(登録者名簿にないのに停止リストに載っている = データ不整合の検出)は全く別の意味になります。「どちらを基準にして、どちらを引くか」を先に整理しましょう。重複行をすべて取得する — サブクエリ + IN で実際の行を特定
GROUP BY + HAVING COUNT(*) > 1 は「重複しているキーの一覧」を返します。しかしそれはどの値が重複しているかが分かるだけで、具体的にどの行か(order_id や amount など他の列の情報)は取得できません。サブクエリ + IN を組み合わせると、重複するキーを持つすべての行を取得できます。
WHERE (user_id, product_id) IN ( SELECT user_id, product_id FROM orders GROUP BY user_id, product_id HAVING COUNT(*) > 1 -- 重複している組のリスト )
orders テーブルには (user_id, product_id) の組み合わせが重複している注文があります。重複している (user_id, product_id) の組を持つすべての注文行を取得してください(対となる行も含め、重複関係にあるすべての行を出力)。
| order_id | user_id | product_id | amount |
|---|---|---|---|
| 1 | 101 | P001 | 3000 |
| 2 | 102 | P002 | 1500 |
| 3 | 101 | P001 | 3000 |
| 4 | 103 | P003 | 2000 |
| 5 | 102 | P002 | 1500 |
| 6 | 103 | P001 | 5000 |
| order_id | user_id | product_id | amount |
|---|---|---|---|
| 1 | 101 | P001 | 3000 |
| 3 | 101 | P001 | 3000 |
| 2 | 102 | P002 | 1500 |
| 5 | 102 | P002 | 1500 |
SELECT order_id, user_id, product_id, amount FROM orders WHERE (user_id, product_id) IN ( SELECT user_id, product_id FROM orders GROUP BY user_id, product_id HAVING COUNT(*) > 1 -- 重複している組だけをリストアップ ) ORDER BY user_id, product_id, order_id; /* 実行順序: 1. サブクエリ GROUP BY + HAVING → 重複キーを抽出 2. FROM orders → 外部で全行読み込み 3. WHERE (...) IN → 重複キーの行だけ残す 4. SELECT → 列を射影 5. ORDER BY user_id, product_id, order_id → 並べ替えて出力 */
LEGEND
① 元データ
FROM ordersorders テーブル(6行)を読み込みます。order_id=1と3 が (101, P001) の重複、order_id=2と5 が (102, P002) の重複です。これらの全行を取得するのが目標です。| order_id | user_id | product_id | amount |
|---|---|---|---|
| 1 | 101 | P001 | 3000 |
| 2 | 102 | P002 | 1500 |
| 3 | 101 | P001 | 3000 |
| 4 | 103 | P003 | 2000 |
| 5 | 102 | P002 | 1500 |
| 6 | 103 | P001 | 5000 |
GROUP BY+HAVING → キーリスト → WHERE IN → 全行取得
GROUP BY + HAVING COUNT(*) > 1 は「どの email が重複しているか」というキーだけを返します。しかし実務では「その行の他の列(注文日、金額など)も見たい」という場面が多く、サブクエリ + IN で「重複キーを持つ全行」を引っ張るのが定番の続き手です。WHERE (user_id, product_id) IN (...) のように複数列をまとめて IN で比較することを行タプル比較と呼びます。PostgreSQL / MySQL 5.5+ で利用可能です。これを使えば WHERE user_id = X AND product_id = Y のように1行ずつ書く必要がなく、サブクエリの複数行結果をそのまま比較できます。INNER JOIN (サブクエリ) dup ON orders.user_id = dup.user_id AND orders.product_id = dup.product_id でも得られます。大規模データでは JOIN の方がオプティマイザに最適化されやすい場合がありますが、可読性は IN の方が高いことが多いです。WHERE key NOT IN (..., NULL, ...) が全行 false になる罠があります。今回は IN(肯定形)なので問題ありませんが、NOT IN を使う際は WHERE key NOT IN (SELECT key FROM ... WHERE key IS NOT NULL) と IS NOT NULL を付ける習慣をつけましょう。2つのリストの共通部分を取る — INTERSECT で積集合を得る
INTERSECT(積集合)は、2つの SELECT 結果の共通部分だけを返す集合演算です。UNION(和集合)、EXCEPT(差集合)と合わせて、集合演算の3兄弟を形成します。
SELECT email FROM A INTERSECT -- A にも B にも存在する行だけ残る(積集合 A ∩ B) SELECT email FROM B; -- 集合演算まとめ UNION -- A ∪ B:足す(重複除去) INTERSECT -- A ∩ B:共通部分を取る EXCEPT -- A − B:引く
INTERSECT ALL を使います(PostgreSQL 対応)。春と夏、2回のキャンペーンの両方に申し込んだユーザーのメールアドレス一覧を取得してください。
| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| yamada@ex.com |
| sato@ex.com |
| kimura@ex.com |
| tanaka@ex.com |
| suzuki@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| tanaka@ex.com |
SELECT email FROM campaign_spring INTERSECT -- 両方に存在する行だけ残す(積集合 A ∩ B) SELECT email FROM campaign_summer ORDER BY email; /* 実行順序: 1. SELECT email FROM campaign_spring → 上側の4行を取得 2. SELECT email FROM campaign_summer → 下側の4行を取得 3. INTERSECT → 両方に存在する行だけ残す(A ∩ B) 4. ORDER BY email → email 昇順で並べ替え */
LEGEND
① 上のリスト — campaign_spring
SELECT email FROM campaign_spring春のキャンペーン申込者リスト(4行)です。yamada は spring にしか存在しないため、INTERSECT では除外されます。| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| yamada@ex.com |
spring(4) ∩ summer(4) = 共通3件
UNION(和集合: A ∪ B)、INTERSECT(積集合: A ∩ B)、EXCEPT(差集合: A − B)の3つで構成されます。すべて内部で重複除去を行い、ALL を付ける(UNION ALL / INTERSECT ALL / EXCEPT ALL)と重複を保持します。ORDER BY は全体の末尾に1回だけ書きます。SELECT s.email FROM campaign_spring s INNER JOIN campaign_summer u ON s.email = u.email でも得られます。JOIN は他の列も参照できるので柔軟ですが、INTERSECT は「両方に含まれるか否か」という目的が明確で可読性が高いです。また INTERSECT は重複を自動除去しますが、JOIN は重複行が増える場合があります。spring INTERSECT summer と summer INTERSECT spring は同じ結果になります。ただし列数・型の一致は依然として必要で、ORDER BY も末尾1回のルールは変わりません。IN + サブクエリ(パターン)や INNER JOIN で代替します。本番環境の DB バージョンを確認してから使用しましょう。PostgreSQL では INTERSECT / EXCEPT ともに標準サポートされています。GROUP BY + MIN で代表行を選ぶ — ウィンドウ関数を使わない重複排除
email ごとの代表行は ROW_NUMBER()(ウィンドウ関数)で選べますが、MIN(id) + GROUP BY を使えば、ウィンドウ関数なしに同等の重複排除ができます。古い DB でも動作し、構造がシンプルです。
-- アプローチ(ウィンドウ関数) ROW_NUMBER() OVER (PARTITION BY email ORDER BY id ASC) = 1 -- アプローチ(集計 + サブクエリ) WHERE id IN (SELECT MIN(id) FROM t GROUP BY email) -- 最新を残したい場合は MAX(id) に変えるだけ
contacts テーブルには同じ email アドレスで複数回登録されたデータがあります。ROW_NUMBER() を使わずに、email ごとに最初(最小の contact_id)の1件だけを残した一覧を取得してください。
| contact_id | name | source | |
|---|---|---|---|
| 1 | tanaka@ex.com | 田中一郎 | web |
| 2 | sato@ex.com | 佐藤花子 | web |
| 3 | tanaka@ex.com | 田中太郎 | app |
| 4 | suzuki@ex.com | 鈴木次郎 | web |
| 5 | sato@ex.com | 佐藤美子 | sns |
| 6 | yamada@ex.com | 山田健 | web |
| contact_id | name | source | |
|---|---|---|---|
| 1 | tanaka@ex.com | 田中一郎 | web |
| 2 | sato@ex.com | 佐藤花子 | web |
| 4 | suzuki@ex.com | 鈴木次郎 | web |
| 6 | yamada@ex.com | 山田健 | web |
SELECT c.contact_id, c.email, c.name, c.source FROM contacts c WHERE c.contact_id IN ( SELECT MIN(contact_id) -- 各 email の最小 ID を代表として選ぶ FROM contacts GROUP BY email ) ORDER BY c.contact_id; /* 実行順序: 1. サブクエリ GROUP BY email → 各 email の最小 ID を抽出 2. FROM contacts c → 外部で全行読み込み 3. WHERE contact_id IN (...) → 最小 ID の行に絞る 4. SELECT → 列を射影 5. ORDER BY c.contact_id → 並べ替えて出力 */
LEGEND
① 元データ
FROM contactscontacts テーブル(6行)を読み込みます。email を重複の判定キーとして見ると、tanaka@ex.com(id=1,3)と sato@ex.com(id=2,5)が重複しています。各 email から最小 ID の1行を代表として選びます。| contact_id | name | source | |
|---|---|---|---|
| 1 | tanaka@ex.com | 田中一郎 | web |
| 2 | sato@ex.com | 佐藤花子 | web |
| 3 | tanaka@ex.com | 田中太郎 | app |
| 4 | suzuki@ex.com | 鈴木次郎 | web |
| 5 | sato@ex.com | 佐藤美子 | sns |
| 6 | yamada@ex.com | 山田健 | web |
GROUP BY email → MIN(id) → WHERE IN → 代表1行
ORDER BY created_at DESC のように任意の優先順位で代表行を選べる柔軟性があります。MIN/MAX は「最小 or 最大 ID の行を残す」という固定的な選択のみです。「最古の登録を残す(MIN)」「最新を残す(MAX)」という単純なケースは Q10 で十分ですが、「日付の新しいもの優先、同日なら ID が大きいもの」などの複合条件は ROW_NUMBER を使います。{1, 2, 4, 6} というスカラーのリストを返し、外部クエリの WHERE contact_id IN (1, 2, 4, 6) がそのリストと照合します。Q8 では複数列のタプルで IN を使いましたが、この形は1列のスカラーで IN を使うシンプルな形です。この「サブクエリで条件リストを作り、IN でフィルタ」というパターンは幅広い場面で応用できます。DELETE FROM contacts WHERE contact_id NOT IN (SELECT MIN(contact_id) FROM contacts GROUP BY email) と書きます。NOT IN でサブクエリの逆(代表以外)を削除します。いきなり DELETE せず、まず SELECT で削除対象を確認してから実行するのが安全です。SELECT email, MIN(contact_id), name FROM contacts GROUP BY email と書くと、PostgreSQL / MySQL のほとんどのモードでは name が集計も GROUP BY もされていないためエラーになります。MIN(id) の行の他の列(name, source)を取得するには、ように外部クエリで contacts に JOIN/IN して行全体を取り出す2段構えが必要です。