SQL Serverのテーブルから重複行を消すとき、先に決めておくことが2つあります。どの列が同じなら重複とみなすか、そして重複した行のうちどの1行を残すかです。DELETE文の書き方よりも、こちらのほうが大事です。ここを決めないまま消すと、残すつもりだった新しい行が消えたり、関係のない行まで巻き込んだりします。
この記事では、ROW_NUMBER() で重複グループごとに番号を振り、2番目以降の行だけを消します。進め方は次のとおりです。
- 重複とみなすキー列を決める
- 残す1行の基準(並び順)を決める
- SELECTで削除対象と件数を確かめる
- 削除対象を別テーブルに退避する
- トランザクションの中でDELETEし、結果を確かめてからCOMMITする
例として、同じメールアドレスの顧客が何件も登録されてしまった dbo.Customers テーブルを使います。
| 列 | 内容 |
|---|---|
| Id | 連番の主キー |
| メールアドレス(重複の判定に使う) | |
| Name | 氏名 |
| UpdatedAt | 最終更新日時 |
テーブル名や列名は、自分の環境のものに置き換えてください。
作業前に確認しておくこと
- 本番の前に、テスト環境か同じ構造のコピーで一度最後まで流す
- データベースのバックアップがあるか、復元の手順を誰が知っているかを確かめる
- DELETE権限があるか、作業してよい時間帯か(ほかの処理が同じテーブルを更新していないか)
- 削除する行がほかのテーブルから外部キーで参照されていないか、DELETEトリガーがないか
外部キーで参照しているテーブルは、次のクエリで調べられます。
SELECT name, OBJECT_NAME(parent_object_id) AS referencing_table
FROM sys.foreign_keys
WHERE referenced_object_id = OBJECT_ID('dbo.Customers');
トリガーは、SSMSのオブジェクト エクスプローラーでテーブルを展開し、「トリガー」フォルダーを見れば確認できます。
手順1:重複とみなすキー列を決めて、重複の状況を見る
まず、どの列(または列の組み合わせ)が同じなら同じデータとみなすかを決めます。Emailだけで判定するのか、Email+Nameで判定するのかによって、消える行は変わります。
キーが決まったら、重複しているキーとその件数を出します。
SELECT Email, COUNT(*) AS cnt
FROM dbo.Customers
GROUP BY Email
HAVING COUNT(*) > 1
ORDER BY cnt DESC;
このときに、次の2点も見ておきます。
- NULLの扱い:
PARTITION BYは、キーがNULLの行どうしを1つのグループにまとめます。上のクエリでEmailがNULLの行が出てきて、それを重複として消したくない場合は、以降のクエリにWHERE Email IS NOT NULLを付けて対象から外します(この記事の例では外しています)。 - 大文字・小文字や全角・半角の扱い:同じ文字とみなすかどうかは、列の照合順序(collation)で決まります。
SELECT name, collation_name
FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.Customers');
照合順序の名前に _CI が含まれていれば大文字と小文字を区別せず、_CS なら区別します。_WS は全角と半角を、_KS はひらがなとカタカナを区別する設定で、これらが名前に含まれていなければ区別しません。
「株式会社」と「(株)」のような表記の違いは、この方法では同じものとして扱われません。表記をそろえる名寄せは削除とは別の作業として、先に済ませておきます。
手順2:残す1行の基準を決める
ROW_NUMBER() は、PARTITION BY で分けたグループごとに、ORDER BY の順で1、2、3…と番号を振ります。1番の行を残して2番以降を消すので、ORDER BYの書き方がそのまま「残す行の基準」になります。
| 残したい行 | ORDER BY の例 |
|---|---|
| 最後に更新された行 | UpdatedAt DESC, Id DESC |
| 最初に登録された行 | Id ASC |
| 項目が埋まっている行(例:Phone列がある場合) | CASE WHEN Phone IS NULL THEN 1 ELSE 0 END, UpdatedAt DESC, Id DESC |
注意すること:
- 並び替えに使う列の値が同じ行があると、どちらが1番になるかは決まっていません。最後に主キーのような一意の列を付け足して、何度実行しても同じ行が残るようにします。
- SQL ServerではNULLが最も小さい値として扱われ、昇順なら先頭、降順なら末尾に来ます。UpdatedAtにNULLがある場合、
UpdatedAt DESCではNULLの行が後ろに回るので残りにくくなります。 - 主キーまで含めてすべての列が同じ行だけを消すのであれば、どれを残しても結果は変わらないため
ORDER BY (SELECT NULL)と書く方法もあります。列の値が異なる行が混ざっている場合は使わないでください。
手順3:SELECTで削除対象と件数を確かめる
CTE(WITH句)で番号の列 rn を付け、最初はSELECTだけを実行します。
WITH d AS (
SELECT
Id, Email, Name, UpdatedAt,
ROW_NUMBER() OVER (
PARTITION BY Email
ORDER BY UpdatedAt DESC, Id DESC
) AS rn
FROM dbo.Customers
WHERE Email IS NOT NULL
)
SELECT *
FROM d
WHERE Email IN (SELECT Email FROM d WHERE rn > 1)
ORDER BY Email, rn;
重複したグループごとに、rn = 1(残る行)と rn が2以上の行(消える行)が並んで表示されます。いくつかのグループを実際に見て、残したい行が1番になっていることを確かめます。
次に件数を確かめます。
WITH d AS (
SELECT
ROW_NUMBER() OVER (
PARTITION BY Email
ORDER BY UpdatedAt DESC, Id DESC
) AS rn
FROM dbo.Customers
WHERE Email IS NOT NULL
)
SELECT
COUNT(*) AS target_rows,
SUM(CASE WHEN rn > 1 THEN 1 ELSE 0 END) AS delete_rows,
SUM(CASE WHEN rn = 1 THEN 1 ELSE 0 END) AS keep_rows
FROM d;
| 項目 | 確かめること |
|---|---|
| delete_rows | これから消える件数。値を控えておく |
| keep_rows | SELECT COUNT(DISTINCT Email) FROM dbo.Customers; の結果と一致する |
| target_rows | delete_rows と keep_rows を足した数と一致する |
数字が合わない、または思ったより多い場合は、キー列やWHERE条件に戻って見直します。
手順4:削除する行を別テーブルに退避する
DELETEの前に、消える行をまるごと別のテーブルにコピーしておきます。あとで「この行は残すべきだった」と分かっても、ここから戻せます。
WITH d AS (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY Email
ORDER BY UpdatedAt DESC, Id DESC
) AS rn
FROM dbo.Customers
WHERE Email IS NOT NULL
)
SELECT *
INTO dbo.Customers_dup_20261006
FROM d
WHERE rn > 1;
退避テーブルの件数が、手順3のdelete_rowsと同じか確認します。テーブル名に作業日を入れておくと、何の作業で作ったものかが後から分かります。不要になったら運用ルールに沿って削除します。
手順5:トランザクションの中でDELETEする
SSMSで、次のクエリを実行します。この時点ではCOMMITを書かないでください。
BEGIN TRANSACTION;
WITH d AS (
SELECT
ROW_NUMBER() OVER (
PARTITION BY Email
ORDER BY UpdatedAt DESC, Id DESC
) AS rn
FROM dbo.Customers
WHERE Email IS NOT NULL
)
DELETE FROM d
WHERE rn > 1;
SELECT @@ROWCOUNT AS deleted_rows;
- CTEが参照しているテーブルが1つだけなら、CTEに対してDELETEすると元のテーブルの行が消えます。
@@ROWCOUNTが返すのは直前の文で処理された件数なので、DELETEのすぐ後に書きます。- WITHの1つ前の文はセミコロンで終わっている必要があります。
BEGIN TRANSACTION;の「;」は消さないでください。
deleted_rowsが手順3のdelete_rowsと同じであれば、トランザクションを閉じる前に結果を確かめます。
-- 重複が残っていないか(0行になるはず)
SELECT Email, COUNT(*) AS cnt
FROM dbo.Customers
WHERE Email IS NOT NULL
GROUP BY Email
HAVING COUNT(*) > 1;
-- 残った件数
SELECT COUNT(*) AS total_rows FROM dbo.Customers;
問題がなければ確定し、想定と違えば取り消します。
COMMIT TRANSACTION; -- 問題がない場合
-- ROLLBACK TRANSACTION; -- 想定と違う場合
トランザクションを開いたままにしておくと、同じテーブルを使うほかの処理が待たされることがあります。確認は手早く済ませ、COMMITかROLLBACKで必ず閉じてください。
件数の確認をスクリプトに組み込む場合
ジョブなど、途中で人が確認できない形で実行するときは、件数が想定と違ったら自動でロールバックするように書いておきます。
SET XACT_ABORT ON;
DECLARE @expected int, @deleted int;
WITH d AS (
SELECT ROW_NUMBER() OVER (
PARTITION BY Email ORDER BY UpdatedAt DESC, Id DESC
) AS rn
FROM dbo.Customers
WHERE Email IS NOT NULL
)
SELECT @expected = COUNT(*) FROM d WHERE rn > 1;
BEGIN TRANSACTION;
WITH d AS (
SELECT ROW_NUMBER() OVER (
PARTITION BY Email ORDER BY UpdatedAt DESC, Id DESC
) AS rn
FROM dbo.Customers
WHERE Email IS NOT NULL
)
DELETE FROM d WHERE rn > 1;
SET @deleted = @@ROWCOUNT;
IF @deleted = @expected
COMMIT TRANSACTION;
ELSE
BEGIN
ROLLBACK TRANSACTION;
THROW 50001, N'削除件数が想定と一致しないためロールバックしました。', 1;
END;
件数を数えてからDELETEするまでの間に、ほかの処理が行を追加・更新すると件数がずれてロールバックになります。そうなった場合は、ほかの処理が止まっている時間帯に実行してください。
削除できない・結果がおかしいときの確認
| 症状 | 確認すること |
|---|---|
| DELETEのときにREFERENCE制約と競合するエラーが出る | 消そうとしている行を参照している子テーブルがある。子テーブルの参照先を、残す行のIdに付け替えるかどうかを含めて作業の範囲を見直す |
| 想定より多くの行が消えそう | キー列が足りていない。NULLの行をWHEREで外していない |
| 消えるはずの重複が残る | 照合順序で大文字・小文字や全角・半角が区別されている。値の先頭に空白や見えない文字が入っている |
| 実行するたびに残る行が変わる | ORDER BYの最後に一意の列を入れていない |
| 件数が多くて時間がかかる・ログが大きくなる | 一度に全部消さず、作業する時間帯や分けて実行する方法をDB管理者と相談する |
実行前チェックリスト
- 重複とみなすキー列を決めた
- キーがNULLの行を対象に含めるかどうか決めた
- ORDER BYの最後に一意の列を入れ、残る行を実際に見て確かめた
- 削除する件数(delete_rows)を控えた
- 削除対象を退避テーブルにコピーし、件数が一致した
- BEGIN TRANSACTIONの中でDELETEし、@@ROWCOUNTが想定と一致した
- 重複が0件になったことを確かめてからCOMMITした
テーブルの行は消さず、SELECTの結果から重複を除いて表示したいだけなら、DELETEではなくクエリ側で処理する方法を使ってください。
