SQL 重複データ管理 — DISTINCT・EXCEPT・INTERSECTの基礎

基礎重複データ管理DISTINCTEXCEPT / INTERSECT集合演算PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

複数列 DISTINCT で組み合わせの種類を調べる — 列が増えると結果はどう変わるか

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 には同じユーザーが同じカテゴリを複数回購入した記録があります。「各ユーザーがこれまでに購入したことのあるカテゴリの組み合わせ」を重複なく取得してください。

使用テーブル
▸ purchase_logs
order_iduser_idcategoryamount
1101Books1500
2101Electronics3000
3102Books800
4101Books2000
5102Electronics5000
6103Books1200
7103Books900
期待出力
user_idcategory
101Books
101Electronics
102Books
102Electronics
103Books
模範解答コード
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  → 並べ替えて出力
  */
解説(テーブル変化・ポイント)
SELECT DISTINCT user_id, category FROM purchase_logs 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)と重複しています。
1 / 4
order_iduser_idcategoryamount
1101Books1500
2101Electronics3000
3102Books800
4101Books2000
5102Electronics5000
6103Books1200
7103Books900
purchase_logs: 7行(user_id と category の組み合わせに重複あり)
学習ポイント
MULTI-COLUMN DISTINCT
複数列 DISTINCT — 列の組み合わせ全体でユニーク化
列を増やすほど「別の行」の条件が緩くなり、残る行数が増える
DISTINCT(2列): 7行 → 5種類
SELECT した全列がひとまとめで判定キーになる:DISTINCT は SELECT 句のすべての列を結合した「組み合わせ」を1つのキーとして重複を判定します。DISTINCT user_id, category なら (101, Books) / (101, Electronics) / (102, Books) … のように、ペアが一致しない限り別の行として残ります。このため、列を追加するほどユニークの基準が緩くなり結果行数が増える傾向があります。
「1列 DISTINCT」vs「2列 DISTINCT」— 結果の違い: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 を使います。
DISTINCT(user_id) と書いて括弧が無意味になる:SELECT DISTINCT(user_id), category と書いても括弧は単なるグループ化として扱われ、結局 (user_id, category) の組 に DISTINCT が効きます。意図せず2列判定になり想定より行数が増えてしまいます。DISTINCT はキーワードであり、直後に括弧を付ける書き方は避けましょう。
実務コラム
複数列 DISTINCT は「何の種類一覧を出したいか」を明確化すると自然に使えます。応用例: ユーザーが購入したカテゴリの一覧(EC のレコメンド用)、使われているタグとカテゴリのペア一覧(管理画面)、担当者×プロジェクトの組み合わせ一覧(工数管理)。実務でよくある落とし穴は「列を追加したら重複が残るようになった」という現象で、その原因の多くは不要な列の混入です。SQL を書く前に「何列の組み合わせでユニーク化したいか」を一言で説明できるか確かめる習慣が、バグを防ぎます。
QUESTION 7

差集合で「片方だけ」のデータを得る — EXCEPT で差分を検出

EXCEPT集合演算差集合差分検出
前提知識

UNION は2つのリストを「足す」集合演算です。EXCEPT(Oracle では MINUS)は「引く」集合演算で、左の SELECT 結果から右の SELECT 結果に含まれる行を除去します。

SELECT email FROM A
EXCEPT             -- A に存在して B に存在しない行だけ残る(差集合 A − B)
SELECT email FROM B;
順序が結果を変える:A EXCEPT BB EXCEPT A は異なる結果になります。UNION と違い、EXCEPT は非可換です。どちらから引くかを常に意識してください。また EXCEPT も内部で重複除去が行われます(A 側に同じ値が2件あっても1件になる)。
問題

all_subscribers には全メルマガ登録者が、unsubscribed には配信停止リストが格納されています。配信停止していない「現在の有効な登録者」のメールアドレス一覧を取得してください。

使用テーブル
▸ all_subscribers
email
tanaka@ex.com
sato@ex.com
suzuki@ex.com
yamada@ex.com
▸ unsubscribed
email
sato@ex.com
yamada@ex.com
期待出力
email
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 昇順で並べ替え
  */
解説(テーブル変化・ポイント)
SELECT email FROM all_subscribers EXCEPT SELECT email FROM unsubscribed ORDER BY email;
LEGEND
データ取得・読込対象
① 上のリスト — all_subscribers
SELECT email FROM all_subscribers全メルマガ登録者のリスト(4行)です。ここから配信停止リストに含まれる行を EXCEPT で除去します。
1 / 4
email
tanaka@ex.com
sato@ex.com
suzuki@ex.com
yamada@ex.com
all_subscribers: 4行
学習ポイント
SET DIFFERENCE
差集合 A − B — 左リストから右リストに含まれる行を除去
UNION(足す)の逆方向:除外リストを引いて有効行のみ残す
all_subs(4) EXCEPT unsubscribed(2) = 2件
EXCEPT の動作:A の行が B に存在するかをチェックして除去:EXCEPT は A の各行を B全体と比較し、B に存在する行を取り除きます。また内部で重複除去も行うため、A 側に同じメールが2件あっても結果は1件になります。重複を残したい場合は EXCEPT ALL を使います(PostgreSQL 対応)。
EXCEPT vs NOT IN の使い分け:同じ結果を WHERE email NOT IN (SELECT email FROM unsubscribed) でも得られますが、EXCEPT の方が意図が明確で読みやすいです。ただし NOT IN はNULL に注意が必要(サブクエリに NULL が含まれると全行が除外されるバグが起きやすい)。EXCEPT は NULL を含む行も正しく処理するため、差分検出には EXCEPT の方が安全です。
集合演算3兄弟 — UNION / EXCEPT / INTERSECT:Q5 で UNION(和集合: A ∪ B)、Q7 で EXCEPT(差集合: A − B)を学びました。次の Q9 では INTERSECT(積集合: A ∩ B)を扱います。3つ合わせると「足す・引く・共通部分を取る」という集合操作を SQL だけで完結させられます。
アンチパターン
A EXCEPT B と B EXCEPT A を混同する:EXCEPT は順序に依存する非可換演算です。all_subscribers EXCEPT unsubscribed(有効な登録者を取得)と unsubscribed EXCEPT all_subscribers(登録者名簿にないのに停止リストに載っている = データ不整合の検出)は全く別の意味になります。「どちらを基準にして、どちらを引くか」を先に整理しましょう。
各 SELECT に ORDER BY を付ける:UNION と同様、EXCEPT でも各 SELECT には ORDER BY を付けられません。並べ替えは全体の末尾に1回だけ書きます。また両 SELECT の列数・型が合わないとエラーになるため、結合する SELECT は列の数・並び・型を揃えることが必須条件です。
実務コラム
差分検出は実務で頻出のユースケースです。応用例: 配信停止者を除いた有効リスト生成、前月にいたが今月いなくなったユーザーの離脱検出、マスタには存在するが実績テーブルに未登場の項目洗い出し。NOT IN / NOT EXISTS を使うより EXCEPT の方が意図が明確で NULL 安全です。ただし EXCEPT は PostgreSQL/SQL Server では使えますが MySQL(5系以前)では非対応のため、対象 DB を確認してから使いましょう。
QUESTION 8

重複行をすべて取得する — サブクエリ + IN で実際の行を特定

サブクエリIN句重複行取得GROUP BY + HAVING
前提知識

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   -- 重複している組のリスト
)
2ステップが実務の定番:まず GROUP BY + HAVING で「どのキーが重複しているか」を確認し、次にサブクエリ + IN で「そのキーを持つ全行」を取得するのが実務での典型的な流れです。
問題

orders テーブルには (user_id, product_id) の組み合わせが重複している注文があります。重複している (user_id, product_id) の組を持つすべての注文行を取得してください(対となる行も含め、重複関係にあるすべての行を出力)。

使用テーブル
▸ orders
order_iduser_idproduct_idamount
1101P0013000
2102P0021500
3101P0013000
4103P0032000
5102P0021500
6103P0015000
期待出力
order_iduser_idproduct_idamount
1101P0013000
3101P0013000
2102P0021500
5102P0021500
模範解答コード
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  → 並べ替えて出力
  */
解説(テーブル変化・ポイント)
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;
LEGEND
データ取得・読込対象
① 元データ
FROM ordersorders テーブル(6行)を読み込みます。order_id=1と3 が (101, P001) の重複、order_id=2と5 が (102, P002) の重複です。これらの全行を取得するのが目標です。
1 / 4
order_iduser_idproduct_idamount
1101P0013000
2102P0021500
3101P0013000
4103P0032000
5102P0021500
6103P0015000
orders: 6行(重複あり)
学習ポイント
DUPLICATE ROW RETRIEVAL
重複行の全取得 — サブクエリで重複キー検出 → IN で行を絞る
Q3(重複キー一覧)の発展: 具体的な行情報も含めて取得
GROUP BY+HAVING → キーリスト → WHERE IN → 全行取得
Q3(重複キーの検出)と Q8(重複行の取得)の2ステップ:GROUP BY + HAVING COUNT(*) > 1 は「どの email が重複しているか」というキーだけを返します。しかし実務では「その行の他の列(注文日、金額など)も見たい」という場面が多く、サブクエリ + IN で「重複キーを持つ全行」を引っ張るのが定番の続き手です。
複数列を IN のキーにする行タプル比較:WHERE (user_id, product_id) IN (...) のように複数列をまとめて IN で比較することを行タプル比較と呼びます。PostgreSQL / MySQL 5.5+ で利用可能です。これを使えば WHERE user_id = X AND product_id = Y のように1行ずつ書く必要がなく、サブクエリの複数行結果をそのまま比較できます。
JOIN での書き換えも等価:同じ結果を INNER JOIN (サブクエリ) dup ON orders.user_id = dup.user_id AND orders.product_id = dup.product_id でも得られます。大規模データでは JOIN の方がオプティマイザに最適化されやすい場合がありますが、可読性は IN の方が高いことが多いです。
アンチパターン
Q3 だけで満足して実際の行を確認しない:GROUP BY + HAVING で重複キーを見つけた後、「何件重複していたか分かったからOK」とそこで終わると、どの行を削除すべきか(最新を残す?最古を残す?)が判断できません。重複対応は「検出 → 全行確認 → 削除行の選定 → DELETE」の順で進めるのが安全です。
サブクエリが NULL を返す場合に全行が除外される:NOT IN を使う場合に限りますが、サブクエリの結果に NULL が含まれると 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回注文していないか確認)、二重払いの検出(同一金額・同一日時の決済が2件ある場合)、ユニーク制約追加前のクレンジング(どの行を残してどの行を削除するかを確認)。このクエリで全行を特定したあと、ROW_NUMBER で「残す行(rn=1)と削除する行(rn>1)」を仕分ければ、完全な重複排除ワークフローが完成します。
QUESTION 9

2つのリストの共通部分を取る — INTERSECT で積集合を得る

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 も内部で重複除去が入る:片方のリストに同じ値が2件あっても、INTERSECT の結果には1件しか現れません。重複を保持したい場合は INTERSECT ALL を使います(PostgreSQL 対応)。
問題

春と夏、2回のキャンペーンの両方に申し込んだユーザーのメールアドレス一覧を取得してください。

使用テーブル
▸ campaign_spring
email
tanaka@ex.com
sato@ex.com
suzuki@ex.com
yamada@ex.com
▸ campaign_summer
email
sato@ex.com
kimura@ex.com
tanaka@ex.com
suzuki@ex.com
期待出力
email
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 昇順で並べ替え
  */
解説(テーブル変化・ポイント)
SELECT email FROM campaign_spring INTERSECT SELECT email FROM campaign_summer ORDER BY email;
LEGEND
データ取得・読込対象
① 上のリスト — campaign_spring
SELECT email FROM campaign_spring春のキャンペーン申込者リスト(4行)です。yamada は spring にしか存在しないため、INTERSECT では除外されます。
1 / 4
email
tanaka@ex.com
sato@ex.com
suzuki@ex.com
yamada@ex.com
campaign_spring: 4行
学習ポイント
SET INTERSECTION
積集合 A ∩ B — 両方のリストに存在する行だけを返す
UNION(和) / EXCEPT(差) と合わせて集合演算3兄弟が完成
spring(4) ∩ summer(4) = 共通3件
集合演算3兄弟の整理:SQL の集合演算は UNION(和集合: A ∪ B)、INTERSECT(積集合: A ∩ B)、EXCEPT(差集合: A − B)の3つで構成されます。すべて内部で重複除去を行い、ALL を付ける(UNION ALL / INTERSECT ALL / EXCEPT ALL)と重複を保持します。ORDER BY は全体の末尾に1回だけ書きます。
INTERSECT と INNER JOIN の違い:同じ結果を SELECT s.email FROM campaign_spring s INNER JOIN campaign_summer u ON s.email = u.email でも得られます。JOIN は他の列も参照できるので柔軟ですが、INTERSECT は「両方に含まれるか否か」という目的が明確で可読性が高いです。また INTERSECT は重複を自動除去しますが、JOIN は重複行が増える場合があります。
INTERSECT の対称性(可換性):EXCEPT と違い INTERSECT は可換です(A ∩ B = B ∩ A)。spring INTERSECT summersummer INTERSECT spring は同じ結果になります。ただし列数・型の一致は依然として必要で、ORDER BY も末尾1回のルールは変わりません。
アンチパターン
INNER JOIN を使って重複が発生する:INTERSECT の代わりに INNER JOIN を使う場合、片方に重複行があると JOIN 結果でも重複が増えます。例えば sato が spring に2件あると JOIN 結果でも sato が2件返ります。INTERSECT なら自動で除去されますが、JOIN の場合は DISTINCT を別途追加する必要があります。
MySQL では INTERSECT が使えない(旧バージョン):MySQL 8.0.31 未満では INTERSECT が非対応です。その場合は IN + サブクエリ(パターン)や INNER JOIN で代替します。本番環境の DB バージョンを確認してから使用しましょう。PostgreSQL では INTERSECT / EXCEPT ともに標準サポートされています。
実務コラム
共通部分の検出は、複数データソースの突合によく使います。応用例: 両方のキャンペーンに申し込んだヘビーユーザーの特定(重複顧客へのセグメント配信)、旧システムと新システムの両方に存在するマスタの確認(移行後の整合性チェック)、先月も今月も購入した継続顧客の抽出(リテンション分析)。集合演算3兄弟(UNION/INTERSECT/EXCEPT)を使いこなすことで、複雑な「どれに属するか」の絞り込みをシンプルな SQL で表現できるようになります。
QUESTION 10

GROUP BY + MIN で代表行を選ぶ — ウィンドウ関数を使わない重複排除

MIN / MAXGROUP BY重複排除サブクエリ + IN
前提知識

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) に変えるだけ
MIN は「最小 ID の行を残す」:MIN(id) は contact_id が最小の行を代表として選びます。「最古の登録を残す」なら MIN、「最新の登録を残す」なら MAX(id) と使い分けます。ROW_NUMBER は ORDER BY で任意の優先順位を指定できるため、複合的な優先順位が必要な場面ではより柔軟です。
問題

contacts テーブルには同じ email アドレスで複数回登録されたデータがあります。ROW_NUMBER() を使わずに、email ごとに最初(最小の contact_id)の1件だけを残した一覧を取得してください。

使用テーブル
▸ contacts
contact_idemailnamesource
1tanaka@ex.com田中一郎web
2sato@ex.com佐藤花子web
3tanaka@ex.com田中太郎app
4suzuki@ex.com鈴木次郎web
5sato@ex.com佐藤美子sns
6yamada@ex.com山田健web
期待出力
contact_idemailnamesource
1tanaka@ex.com田中一郎web
2sato@ex.com佐藤花子web
4suzuki@ex.com鈴木次郎web
6yamada@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      → 並べ替えて出力
  */
解説(テーブル変化・ポイント)
SELECT c.contact_id, c.email, c.name, c.source FROM contacts c WHERE c.contact_id IN ( SELECT MIN(contact_id) FROM contacts GROUP BY email ) 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行を代表として選びます。
1 / 4
contact_idemailnamesource
1tanaka@ex.com田中一郎web
2sato@ex.com佐藤花子web
3tanaka@ex.com田中太郎app
4suzuki@ex.com鈴木次郎web
5sato@ex.com佐藤美子sns
6yamada@ex.com山田健web
contacts: 6行(email に重複あり)
学習ポイント
AGGREGATE DEDUPLICATION
MIN + GROUP BY による重複排除 — Window関数不要で代表行を選ぶ
Q4(ROW_NUMBER)の代替: シンプル3ステップで email 重複を排除
GROUP BY email → MIN(id) → WHERE IN → 代表1行
Q4(ROW_NUMBER) vs Q10(MIN+GROUP BY) — どちらを選ぶか:ROW_NUMBER は ORDER BY created_at DESC のように任意の優先順位で代表行を選べる柔軟性があります。MIN/MAX は「最小 or 最大 ID の行を残す」という固定的な選択のみです。「最古の登録を残す(MIN)」「最新を残す(MAX)」という単純なケースは Q10 で十分ですが、「日付の新しいもの優先、同日なら ID が大きいもの」などの複合条件は ROW_NUMBER を使います。
サブクエリ + IN の動作原理:サブクエリが {1, 2, 4, 6} というスカラーのリストを返し、外部クエリの WHERE contact_id IN (1, 2, 4, 6) がそのリストと照合します。Q8 では複数列のタプルで IN を使いましたが、この形は1列のスカラーで IN を使うシンプルな形です。この「サブクエリで条件リストを作り、IN でフィルタ」というパターンは幅広い場面で応用できます。
DELETE への応用 — MIN(id) 以外を削除:重複を実際にクレンジングするには DELETE FROM contacts WHERE contact_id NOT IN (SELECT MIN(contact_id) FROM contacts GROUP BY email) と書きます。NOT IN でサブクエリの逆(代表以外)を削除します。いきなり DELETE せず、まず SELECT で削除対象を確認してから実行するのが安全です。
アンチパターン
MIN(id) だけ取得できて他の列が取れない錯覚:SELECT email, MIN(contact_id), name FROM contacts GROUP BY email と書くと、PostgreSQL / MySQL のほとんどのモードでは name が集計も GROUP BY もされていないためエラーになります。MIN(id) の行の他の列(name, source)を取得するには、ように外部クエリで contacts に JOIN/IN して行全体を取り出す2段構えが必要です。
contact_id が連番でない場合の誤解:MIN(contact_id) は「最も小さい ID の行」を返しますが、contact_id が連番でない場合(UUID や飛び番のシーケンスなど)は「最古の登録」とは限りません。「最古を残したい」なら MIN(created_at) をキーにした JOIN、「最新を残したい」なら MAX(created_at) が正確です。IDのみに頼るのは連番 ID の仮定が成り立つ場合に限りましょう。
実務コラム
MIN/MAX + GROUP BY による重複排除は、ウィンドウ関数が使えない旧環境や、シンプルさを優先したい場面で活躍します。応用例: 同一メールの登録を最初の1件に統一(会員マスタクレンジング)、同一顧客の重複レコードを ID 最小に名寄せ、DELETE 前の確認クエリ。Q1〜Q10 で学んだパターンを組み合わせると「重複検出(Q3/Q8)→ 代表行選定(Q4/Q10)→ 削除(NOT IN + DELETE)」の完全なクレンジングワークフローが実現できます。