SQLで重複した行を排除するには DISTINCT を使います。しかし、「SELECT結果の重複を除く」だけではなく、「テーブルから重複レコードを物理的に削除する」「重複なしの件数を数える」など、用途によって書き方が異なります。
本記事では DISTINCT の基本から、GROUP BY との使い分け、集約関数との組み合わせ、重複行の物理削除、パフォーマンスまで体系的に解説します。
この記事で分かること
- SELECT DISTINCT の基本構文と動作原理
- 複数列に DISTINCT を指定したときの挙動
- DISTINCT と GROUP BY の違い・使い分け
- COUNT(DISTINCT col) / SUM(DISTINCT col) の使い方
- DISTINCT ON(PostgreSQL 独自)の使い方
- NULL は DISTINCT でどう扱われるか
- テーブルから重複レコードを物理削除する方法
- DISTINCT のパフォーマンス影響と代替手段
SELECT DISTINCT の基本
SELECT に DISTINCT を付けると、結果セットから重複行が排除されます。SELECT した全列の組み合わせが完全に一致する行が1つにまとめられます。
-- 従業員テーブルから所属部署の一覧を取得(重複なし) SELECT DISTINCT department FROM employees; -- 結果: 営業部 / 開発部 / 総務部(各1行) -- DISTINCT なし(重複あり) SELECT department FROM employees; -- 結果: 営業部 / 営業部 / 開発部 / 営業部 / 総務部 / 開発部 ...(全行)
-- ALL は明示的に書いてもデフォルトと同じ(重複をそのまま返す) SELECT ALL department FROM employees; -- 全行返す(デフォルト動作) SELECT DISTINCT department FROM employees; -- 重複排除して返す
複数列に DISTINCT を指定する
DISTINCT は「指定した全列の組み合わせ」で重複を判定します。特定の1列だけに適用するのではなく、SELECT句の全列がセットで判定対象になります。
-- 部署と役職の一意な組み合わせを取得 SELECT DISTINCT department, role FROM employees; -- 結果: -- 営業部 | マネージャー -- 営業部 | メンバー ← department は同じでも role が異なるので別行 -- 開発部 | マネージャー -- 開発部 | メンバー
よくある誤解:SELECT DISTINCT department, name は「department の重複を消してname も取得する」ではありません。department + name の組み合わせで重複判定します。department だけの重複を消して1行にしたい場合は GROUP BY や DISTINCT ON を使います。
DISTINCT と GROUP BY の違い
DISTINCT と GROUP BY はどちらも重複排除に使えますが、目的が異なります。
| 比較項目 | DISTINCT | GROUP BY |
|---|---|---|
| 主な目的 | 結果セットの重複行を排除する | グループ化して集約関数を適用する |
| 集約関数 | 使えない(COUNT内を除く) | SUM/AVG/COUNT/MAX/MINと組み合わせる |
| HAVING | 使えない | グループの絞り込みに使える |
| パフォーマンス | ソートまたはハッシュで重複排除 | 同様のコスト(オプティマイザ次第) |
| 可読性 | 単純な重複排除なら直感的 | 集約処理の意図が明確になる |
-- DISTINCT で重複排除 SELECT DISTINCT department FROM employees; -- GROUP BY で同じ結果 SELECT department FROM employees GROUP BY department; -- どちらも結果は同じ。集約関数を使わないなら DISTINCT の方がシンプル
-- 部署ごとの人数を数える(DISTINCT では不可能) SELECT department, COUNT(*) AS member_count FROM employees GROUP BY department; -- 結果: -- 営業部 | 15 -- 開発部 | 22 -- 総務部 | 8 -- 重複が2件以上ある行だけ抽出(HAVING) SELECT department, role, COUNT(*) AS cnt FROM employees GROUP BY department, role HAVING COUNT(*) > 1;
使い分けの基準:
- 単純に「一意な値の一覧がほしい」→ DISTINCT
- 「グループ化して集計したい」「重複件数を知りたい」→ GROUP BY
- 両者の結果が同じ場合、パフォーマンスも通常同じ(オプティマイザが同じ実行計画を選ぶことが多い)
集約関数と DISTINCT の組み合わせ
集約関数の引数に DISTINCT を指定すると、重複を排除してから集約が行われます。
COUNT(DISTINCT col) — 重複なしの件数
-- 従業員テーブルの部署数を数える(重複を排除してカウント) SELECT COUNT(DISTINCT department) AS dept_count FROM employees; -- 結果: 3(営業部/開発部/総務部) -- DISTINCT なしの COUNT(全行をカウント) SELECT COUNT(department) AS total_rows FROM employees; -- 結果: 45(NULL以外の全行数)
-- 部署ごとに「何種類の役職があるか」を数える
SELECT
department,
COUNT(DISTINCT role) AS role_types,
COUNT(*) AS total_members
FROM employees
GROUP BY department;
-- 結果:
-- 営業部 | 3 | 15 ← 3種類の役職に15人
-- 開発部 | 4 | 22
-- 総務部 | 2 | 8
SUM(DISTINCT) / AVG(DISTINCT)
SUM / AVG にも DISTINCT を指定できますが、使う場面は限られます。
-- 注文テーブルで、重複する金額を排除して合計(あまり使わない)
SELECT SUM(DISTINCT amount) FROM orders;
-- 1000円の注文が3件あっても 1000 を1回だけ合計する
-- 実務で使う場面:JOIN で行が膨らんだとき
SELECT
c.customer_id,
c.customer_name,
SUM(DISTINCT o.amount) AS unique_total -- JOIN重複排除
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY c.customer_id, c.customer_name;
SUM(DISTINCT) は意図しない結果になりやすい:同じ金額の異なる注文が存在する場合、片方が無視されます。JOINによる行の膨張が原因なら、サブクエリで先に集計してからJOINするのが安全です。
DISTINCT ON — PostgreSQL 独自機能
PostgreSQL の DISTINCT ON (列) は、指定した列の値ごとに最初の1行だけを返します。通常の DISTINCT では不可能な「グループの先頭行を取得する」処理が簡潔に書けます。
-- 各部署で最も給与が高い従業員を1人だけ取得
SELECT DISTINCT ON (department)
department,
name,
salary
FROM employees
ORDER BY department, salary DESC;
-- 結果: 部署ごとに給与が最も高い1名ずつが返る
-- ORDER BY で先頭に来る行が選ばれる
-- ORDER BY を省略するとどの行が選ばれるか不定
他のRDBMSでの代替:DISTINCT ON は PostgreSQL 限定です。MySQL / SQL Server / Oracle ではウィンドウ関数 ROW_NUMBER() で同等の結果を得られます。
-- 各部署で最も給与が高い従業員を1人だけ取得
SELECT department, name, salary
FROM (
SELECT
department,
name,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS rn
FROM employees
) ranked
WHERE rn = 1;
NULL の扱い
DISTINCT における NULL の扱いには注意が必要です。
| 場面 | NULLの扱い |
|---|---|
| SELECT DISTINCT | NULL同士は同じ値として1行にまとめられる |
| COUNT(DISTINCT col) | NULLはカウントされない |
| GROUP BY | NULLは1つのグループにまとめられる |
-- department に NULL がある場合 -- データ: 営業部, 開発部, NULL, NULL, 営業部 SELECT DISTINCT department FROM employees; -- 結果: 営業部 / 開発部 / NULL(NULLは1行にまとまる) -- COUNT(DISTINCT) は NULL を無視する SELECT COUNT(DISTINCT department) FROM employees; -- 結果: 2(営業部と開発部のみ。NULLはカウント外) -- NULLもカウントしたい場合 SELECT COUNT(DISTINCT COALESCE(department, '(未設定)')) FROM employees; -- 結果: 3(営業部 / 開発部 / (未設定))
テーブルから重複行を物理削除する
SELECT DISTINCT は「結果の表示で重複を除く」だけで、テーブルのデータは変わりません。テーブルから実際に重複レコードを削除(DELETE)するにはDML操作が必要です。
方法1:ROW_NUMBER で重複を特定して削除
-- 同じ name + email の重複行のうち、id が大きい方を削除
DELETE FROM employees
WHERE id IN (
SELECT id FROM (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY name, email
ORDER BY id -- 最小IDを残す
) AS rn
FROM employees
) ranked
WHERE rn > 1
);
-- PostgreSQL: ctid(物理行ID)を使って重複を削除
DELETE FROM employees
WHERE ctid NOT IN (
SELECT MIN(ctid)
FROM employees
GROUP BY name, email
);
-- CTE で直接 DELETE(SQL Server はCTEを更新可能)
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY name, email
ORDER BY id
) AS rn
FROM employees
)
DELETE FROM ranked WHERE rn > 1;
-- Oracle: ROWID を使って重複の古い行を残す
DELETE FROM employees
WHERE ROWID NOT IN (
SELECT MIN(ROWID)
FROM employees
GROUP BY name, email
);
方法2:一時テーブルに DISTINCT を移してから入れ替え
大量の重複がある場合は、一時テーブルに DISTINCT データを退避し、元テーブルを空にしてから挿入し直す方法が高速です。
-- Step1: 重複排除したデータを退避 CREATE TABLE employees_clean AS SELECT DISTINCT * FROM employees; -- Oracle: CREATE TABLE ... AS SELECT -- PostgreSQL: CREATE TABLE ... AS SELECT -- SQL Server: SELECT ... INTO employees_clean FROM (SELECT DISTINCT ...) -- Step2: 元テーブルを空にする TRUNCATE TABLE employees; -- Step3: 退避データを挿入 INSERT INTO employees SELECT * FROM employees_clean; -- Step4: 一時テーブルを削除 DROP TABLE employees_clean;
TRUNCATE は外部キー制約があると使えない場合があります。また、TRUNCATE はロールバックできない(MySQL/Oracle)ため、本番環境では事前にバックアップを取ってから実行してください。
重複を事前に防ぐ — UNIQUE制約
重複データの事後削除よりも、そもそも重複が入らないようにする方が安全です。
-- テーブル作成時に設定
CREATE TABLE employees (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE, -- メールアドレスの重複を禁止
name VARCHAR(100) NOT NULL,
department VARCHAR(100)
);
-- 既存テーブルに追加
ALTER TABLE employees
ADD CONSTRAINT uq_employees_email UNIQUE (email);
-- 複合UNIQUE(name + department の組み合わせで一意)
ALTER TABLE employees
ADD CONSTRAINT uq_emp_name_dept UNIQUE (name, department);
パフォーマンスの注意点
| 注意点 | 対処方法 |
|---|---|
| DISTINCT は全行をソートまたはハッシュする | WHERE で事前に絞り込んで対象行を減らす |
| 大量列の DISTINCT はコストが高い | 必要な列だけ SELECT する。SELECT DISTINCT * は避ける |
| DISTINCT が不要なのに付けている | JOINの設計を見直す(重複の原因がJOINにある場合が多い) |
| COUNT(DISTINCT) が遅い | 対象列にインデックスを追加する(カバリングインデックス) |
-- NG: JOINで重複が発生 → DISTINCTで消している
SELECT DISTINCT c.customer_id, c.name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
-- 注文が複数ある顧客が重複する → DISTINCT で消している
-- OK: EXISTS で書き換え(重複が発生しない)
SELECT c.customer_id, c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);
-- JOINせずにEXISTSで存在チェックするため重複しない
DISTINCT を書くときは「なぜ重複しているのか」を考える:DISTINCT はデータ設計やJOINの問題を隠してしまうことがあります。安易にDISTINCTを付ける前に、重複の原因を特定しましょう。
-- MySQL: EXPLAIN で確認 EXPLAIN SELECT DISTINCT department FROM employees; -- Extra に "Using temporary" や "Using filesort" が出るかチェック -- PostgreSQL: EXPLAIN ANALYZE EXPLAIN ANALYZE SELECT DISTINCT department FROM employees; -- HashAggregate か Sort + Unique が使われているか確認
まとめ
| 目的 | 方法 |
|---|---|
| SELECT結果の重複排除 | SELECT DISTINCT col1, col2 … |
| 重複なしの件数カウント | COUNT(DISTINCT col) |
| グループ別集計 | GROUP BY(集約関数を使う場合はこちら) |
| 各グループの先頭1行 | DISTINCT ON(PostgreSQL)/ ROW_NUMBER(全RDBMS) |
| テーブルの重複行を物理削除 | ROW_NUMBER + DELETE / CTID・ROWID で DELETE |
| 重複の事前防止 | UNIQUE制約の追加 |
- 単純な重複排除には SELECT DISTINCT(全列の組み合わせで判定)
- 集約関数と組み合わせるなら COUNT(DISTINCT col) — 件数カウントの詳細はCOUNT関数完全ガイドを参照
- GROUP BY と DISTINCT は結果が同じ場合も多いが、集計が必要なら GROUP BY を使う — 集計の詳細はGROUP BY + SUM 集計ガイドを参照
- NULL同士は同じ値として重複排除される(COUNT(DISTINCT) ではカウント外)
- テーブルから重複行を物理削除するには ROW_NUMBER + DELETE(RDBMS別に構文が異なる)
- DISTINCT を多用しているならJOINの設計を見直す — DISTINCT は問題の根本解決ではない

