SQL で重複を見つけるには、一意であるべき列で行をグループ化し、2行以上あるグループを残します。さらにウィンドウ関数や自己結合を使えば重複している行そのものを確認でき、どれを残すか判断できます。例は PostgreSQL、MySQL、SQLite、SQL Server で動作します。
SQL で重複を見つける方法
GROUP BY は同じ値の行をまとめ、COUNT(*) は各グループの行数を数え、HAVING は2行以上あるグループだけを残します:
SELECT columns, COUNT(*)
FROM table_name
GROUP BY columns
HAVING COUNT(*) > 1;
たとえば、複数の顧客が使っているメールアドレスを見つけるには次のようにします:
-- Input: customers
-- id | name | email
-- ---+----------------+----------------------
-- 1 | Maria Lopez | maria.lopez@gmail.com
-- 2 | Sam Patel | sam@patel.dev
-- 3 | M. Lopez | maria.lopez@gmail.com
-- 4 | Kenji Watanabe | kenji@watanabe.jp
SELECT email, COUNT(*) AS copies
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
-- Output:
-- email | copies
-- ----------------------+-------
-- maria.lopez@gmail.com | 2
複数の列がすべて一致する場合のみ重複とみなすには、それらの列をすべて SELECT と GROUP BY の両方に記述します。
知っておくべきルール
-
SELECT する列はすべて
GROUP BYに含めるか、集計関数の中に入れる必要があります。上のクエリにnameを追加しても、重複している人の名前は表示されません。エラーになります。ただし SQLite ではメールごとに1つの名前が表示されます:
-- Input: the customers table above
SELECT name, email, COUNT(*) AS copies
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
-- ERROR: column "customers.name" must appear in the GROUP BY clause or be used in an aggregate function
重複している行をすべて確認するには、「別の書き方」にあるクエリを使ってください。
-
NULL は1つのグループにまとめられ、大文字と小文字は区別されます。NULL のメールはすべて同じグループに入るため互いに重複しているように見えますが、
Ana@Silva.devとana@silva.devは重複とみなされません:
-- Input: users
-- id | email
-- ---+--------------
-- 1 | ana@silva.dev
-- 2 | NULL
-- 3 | Ana@Silva.dev
-- 4 | NULL
SELECT email, COUNT(*) AS copies
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- Output:
-- email | copies
-- ------+-------
-- NULL | 2
-- MySQL and SQL Server ignore case by default, so they also return ana@silva.dev | 2
-- Skip the NULLs and ignore case:
SELECT LOWER(email) AS email, COUNT(*) AS copies
FROM users
WHERE email IS NOT NULL
GROUP BY LOWER(email)
HAVING COUNT(*) > 1;
-- Output:
-- email | copies
-- --------------+-------
-- ana@silva.dev | 2
別の書き方
GROUP BY は重複した値ごとに1行を返します。重複している行そのものを取得するには、代わりにウィンドウで数えます。COUNT(*) OVER (PARTITION BY email) は、各メールの件数をそのメールを持つすべての行に付けます:
-- Input: the customers table above
SELECT id, name, email, copies
FROM (
SELECT id, name, email, COUNT(*) OVER (PARTITION BY email) AS copies
FROM customers
) AS c
WHERE copies > 1
ORDER BY id;
-- Output:
-- id | name | email | copies
-- ---+-------------+-----------------------+-------
-- 1 | Maria Lopez | maria.lopez@gmail.com | 2
-- 3 | M. Lopez | maria.lopez@gmail.com | 2
EXISTS を使えば、ウィンドウ関数なしで同じことができます。同じメールを持つ別の行が存在する行だけを残します:
-- Input: the customers table above
SELECT id, name, email
FROM customers c
WHERE EXISTS (
SELECT 1 FROM customers other
WHERE other.email = c.email AND other.id <> c.id
);
-- Output:
-- id | name | email
-- ---+-------------+----------------------
-- 1 | Maria Lopez | maria.lopez@gmail.com
-- 3 | M. Lopez | maria.lopez@gmail.com
重複を探すべきなのはどんなとき?
重複は、インポート、ダブルクリック、リトライ、制約の不足などによって入り込みます。重複に関してよく必要になる作業は次の4つです。
列が一意かどうかを確認する
UNIQUE 制約を追加する前や、ログインや検索で2行返されるようになったときは、複数回出現する値を一覧にします。GROUP BY と HAVING でそのまま確認できます。
関係するすべての行を確認する
どの値が重複しているかがわかっても、それだけでは修正できません。行全体を見て、どこが違うのか、どれが正しいのかを確認する必要があります。重複した値を同じテーブルに対するクエリに渡しましょう。
余分なコピーに印を付ける
クリーンアップするには、重複の各セットから1行を残して残りを削除します。ROW_NUMBER() は各セット内の行に番号を振るので、1番目以降の行はすべて余分なコピーです。
二重送信を検出する
ボタンのダブルクリックや支払いのリトライでは、ID 以外がすべて一致する2行が残ります。テーブルを自己結合するとそれらがペアになり、t1.id < t2.id で各ペアを1回だけ表示できます。
重複を見つける実例
ダブルブッキングされたホテルの部屋を見つける
1つの部屋は1泊につき1回しか予約できないので、重複とは同じ部屋と同じ日の組み合わせです。両方の列でグループ化します:
-- Input: bookings
-- booking_id | room | night | guest
-- -----------+------+------------+----------
-- 1 | 204 | 2026-11-06 | R. Okafor
-- 2 | 204 | 2026-11-07 | R. Okafor
-- 3 | 310 | 2026-11-06 | L. Chen
-- 4 | 204 | 2026-11-06 | P. Novak
SELECT room, night, COUNT(*) AS bookings
FROM bookings
GROUP BY room, night
HAVING COUNT(*) > 1;
-- Output:
-- room | night | bookings
-- -----+------------+---------
-- 204 | 2026-11-06 | 2
メールアドレスを共有しているアカウントを一覧にする
同じアドレスを大文字・小文字を変えて二重に登録する人がいます。LOWER(email) でパーティション分割すると、両方のアカウントを並べて確認できます:
-- Input: accounts
-- id | username | email
-- ---+----------+----------------
-- 1 | jkim | jin@kim.kr
-- 2 | olivia_r | olivia@reyes.mx
-- 3 | jin.kim | Jin@Kim.kr
-- 4 | noah_b | noah@baker.uk
SELECT id, username, email, accounts_with_email
FROM (
SELECT id, username, email,
COUNT(*) OVER (PARTITION BY LOWER(email)) AS accounts_with_email
FROM accounts
) AS a
WHERE accounts_with_email > 1
ORDER BY id;
-- Output:
-- id | username | email | accounts_with_email
-- ---+----------+------------+--------------------
-- 1 | jkim | jin@kim.kr | 2
-- 3 | jin.kim | Jin@Kim.kr | 2
クリーンアップ前に重複した人物に印を付ける
姓と名が同じユーザーを同一人物とみなし、最も古いレコードを残して、それ以外にラベルを付けます。CTE を使うと番号付けとラベル付けを分けて書けます:
-- Input: users
-- id | first_name | last_name | email
-- ---+------------+-----------+----------------
-- 1 | Priya | Shah | priya@shah.net
-- 2 | Tom | Becker | tom@becker.de
-- 3 | Priya | Shah | pshah@work.com
-- 4 | Lena | Kowalski | lena@kowal.pl
-- 5 | Tom | Becker | tbecker@mail.de
WITH numbered AS (
SELECT id, first_name, last_name, email,
ROW_NUMBER() OVER (PARTITION BY first_name, last_name ORDER BY id) AS copy_number
FROM users
)
SELECT id, first_name, last_name, email,
CASE WHEN copy_number > 1 THEN 'Duplicate' ELSE 'Original' END AS status
FROM numbered
ORDER BY id;
-- Output:
-- id | first_name | last_name | email | status
-- ---+------------+-----------+-----------------+----------
-- 1 | Priya | Shah | priya@shah.net | Original
-- 2 | Tom | Becker | tom@becker.de | Original
-- 3 | Priya | Shah | pshah@work.com | Duplicate
-- 4 | Lena | Kowalski | lena@kowal.pl | Original
-- 5 | Tom | Becker | tbecker@mail.de | Duplicate
Duplicate と印の付いた行が、統合または削除の対象です。削除方法は重複削除ガイドで紹介しています。
誤って二重に注文されたものを見つける
同じ顧客が同じ日に同じ商品を2回注文している場合、おそらく1つの注文が二重に送信されたものです:
-- Input: orders
-- order_id | customer_id | product | order_date
-- ---------+-------------+-----------+-----------
-- 1001 | 42 | Desk Lamp | 2026-03-02
-- 1002 | 42 | Desk Lamp | 2026-03-02
-- 1003 | 42 | Desk Lamp | 2026-03-09
-- 1004 | 57 | Desk Lamp | 2026-03-02
SELECT o1.order_id AS first_order, o2.order_id AS repeat_order,
o1.customer_id, o1.product
FROM orders o1
JOIN orders o2
ON o1.customer_id = o2.customer_id
AND o1.product = o2.product
AND o1.order_date = o2.order_date
AND o1.order_id < o2.order_id;
-- Output:
-- first_order | repeat_order | customer_id | product
-- ------------+--------------+-------------+----------
-- 1001 | 1002 | 42 | Desk Lamp
重複に関する参考資料
このブログの記事:
- SQL で重複を削除するガイド:見つけた余分なコピーを削除する
- SQL Server のテーブルで重複行を検出する:同じ問題を T-SQL で
- SQL COUNT DISTINCT ガイド:重複を除いて値を数える
-
SQL GROUP BY ガイド:グループ化と
HAVINGを詳しく解説 -
SQL の Having と Where の違い:件数のフィルターを
HAVINGに書く理由 - PostgreSQL で一意制約を作成する:新たな重複の混入を防ぐ
- SQL の CTE とサブクエリの比較ガイド:これらのクエリを組み立てる2つの方法
公式ドキュメント:
- PostgreSQL: GROUP BY と HAVING、ウィンドウ関数
- MySQL: SELECT(GROUP BY と HAVING を含む)、ROW_NUMBER()
- SQLite: SELECT(GROUP BY と HAVING を含む)、ウィンドウ関数
- SQL Server: HAVING、ROW_NUMBER
Beekeeper Studioは無料のオープンソースのデータベースGUIです
これまで使った中で最高のSQLクエリ&エディタツールです。データベースの管理に必要なものがすべて揃っています。 - ⭐⭐⭐⭐⭐ Mit
Beekeeper Studioは高速で直感的、そして使いやすいツールです。多数のデータベースに対応し、Windows、Mac、Linuxで快適に動作します。
Beekeeper Studio 利用者の声
「Beekeeper Studio は以前の SQL ワークフローを完全に置き換えました。高速で直感的で、データベース作業がまた楽しくなりました。」
「多くのデータベース GUI を試してきましたが、Beekeeper は機能とシンプルさのバランスが完璧です。とにかくちゃんと動きます。」