【SQL】DISTINCT完全ガイド|重複排除・GROUP BYとの違い・複数列・COUNT(DISTINCT)・NULLの扱い・重複行DELETE・パフォーマンスまで

【SQL】DISTINCT完全ガイド|重複排除・GROUP BYとの違い・複数列・COUNT(DISTINCT)・NULLの扱い・重複行DELETE・パフォーマンスまで SQL

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つにまとめられます。

DISTINCT の基本
-- 従業員テーブルから所属部署の一覧を取得(重複なし)
SELECT DISTINCT department
FROM employees;
-- 結果: 営業部 / 開発部 / 総務部(各1行)

-- DISTINCT なし(重複あり)
SELECT department
FROM employees;
-- 結果: 営業部 / 営業部 / 開発部 / 営業部 / 総務部 / 開発部 ...(全行)
ALL(デフォルト)との対比
-- ALL は明示的に書いてもデフォルトと同じ(重複をそのまま返す)
SELECT ALL department FROM employees;     -- 全行返す(デフォルト動作)
SELECT DISTINCT department FROM employees; -- 重複排除して返す

複数列に DISTINCT を指定する

DISTINCT は「指定した全列の組み合わせ」で重複を判定します。特定の1列だけに適用するのではなく、SELECT句の全列がセットで判定対象になります。

複数列の DISTINCT
-- 部署と役職の一意な組み合わせを取得
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 と GROUP BY
-- DISTINCT で重複排除
SELECT DISTINCT department FROM employees;

-- GROUP BY で同じ結果
SELECT department FROM employees GROUP BY department;

-- どちらも結果は同じ。集約関数を使わないなら DISTINCT の方がシンプル
GROUP BY でしかできないこと — 集約関数
-- 部署ごとの人数を数える(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) — 重複なしの件数

COUNT(DISTINCT) の基本
-- 従業員テーブルの部署数を数える(重複を排除してカウント)
SELECT COUNT(DISTINCT department) AS dept_count
FROM employees;
-- 結果: 3(営業部/開発部/総務部)

-- DISTINCT なしの COUNT(全行をカウント)
SELECT COUNT(department) AS total_rows
FROM employees;
-- 結果: 45(NULL以外の全行数)
COUNT(DISTINCT) と GROUP BY の組み合わせ
-- 部署ごとに「何種類の役職があるか」を数える
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 を指定できますが、使う場面は限られます。

SUM(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 では不可能な「グループの先頭行を取得する」処理が簡潔に書けます。

DISTINCT ON の使い方(PostgreSQL 限定)
-- 各部署で最も給与が高い従業員を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() で同等の結果を得られます。

ROW_NUMBER で DISTINCT ON を代替(全RDBMS)
-- 各部署で最も給与が高い従業員を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つのグループにまとめられる
NULL と DISTINCT の動作確認
-- 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 で重複を特定して削除

MySQL — ROW_NUMBER + DELETE
-- 同じ 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 を使った効率的な削除
-- PostgreSQL: ctid(物理行ID)を使って重複を削除
DELETE FROM employees
WHERE ctid NOT IN (
    SELECT MIN(ctid)
    FROM employees
    GROUP BY name, email
);
SQL Server — CTE + ROW_NUMBER + DELETE
-- 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 を使った削除
-- 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制約

重複データの事後削除よりも、そもそも重複が入らないようにする方が安全です。

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) が遅い 対象列にインデックスを追加する(カバリングインデックス)
DISTINCT が不要なケース — JOINの見直し
-- 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を付ける前に、重複の原因を特定しましょう。

実行計画で 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 は問題の根本解決ではない