SQLで重複を除くときは、まず SELECT DISTINCT が思い浮かびます。ただし、DISTINCTが1行にまとめるのは「SELECTに書いた列がすべて同じ行」だけです。「顧客ごとに1行だけ取り出し、その顧客の最新の注文日と金額も表示したい」という場合は、DISTINCTでは目的の結果を得られません。
この記事では、テーブルの中身は変更せず、SELECTの段階で重複を除いて取得する方法を目的別に整理します。テーブルから重複行を実際に削除する作業は扱いません。
先に決めること:何を重複とみなし、どの行を残すか
どの方法を使うか決める前に、次の2点を言葉にしておきます。ここが曖昧なままだと、件数は合っていても中身が意図と違うSQLになることがあります。
- 重複のキー:どの列が同じなら「同じもの」とみなすか(例:customer_id が同じなら同じ顧客)
- 残す行の基準:キーが同じ行が複数あるとき、どの行を残すか(例:注文日が最も新しい行。同じ日なら注文番号が大きい行)
「どの行でもよい」と考えている場合でも、実行するたびに結果が変わってよいのかは確認しておきます。
目的別の使い分け
| やりたいこと | 使う方法 |
|---|---|
| SELECTした列の組み合わせが完全に同じ行を1行にしたい | DISTINCT |
| キーごとの件数・合計・最大値などの集計値がほしい | GROUP BY |
| キーごとに1行だけ、その行のほかの列もそのまま取りたい | ROW_NUMBER |
| 2つのSELECTの結果を縦につなぎ、重複を除きたい | UNION |
| 2つのSELECTの結果を縦につなぎ、すべて残したい | UNION ALL |
以下では、次の注文テーブル orders を例に説明します。
| order_id | customer_id | order_date | amount | staff |
|---|---|---|---|---|
| 1001 | C01 | 2026-04-03 | 12000 | 佐藤 |
| 1002 | C01 | 2026-05-10 | 8000 | 鈴木 |
| 1003 | C02 | 2026-05-10 | 5000 | 佐藤 |
| 1004 | C02 | 2026-05-10 | 7000 | 田中 |
| 1005 | C03 | 2026-03-21 | 3000 | 鈴木 |
DISTINCTで足りる場合・足りない場合
顧客IDの一覧がほしいだけなら、DISTINCTで足ります。
SELECT DISTINCT customer_id
FROM orders;
結果は C01・C02・C03 の3行です。
ところが、注文日も一緒に表示しようとして列を追加すると、結果が変わります。
SELECT DISTINCT customer_id, order_date
FROM orders;
C01は注文日が2種類あるので2行になります。C02は2件とも同じ日なので1行です。さらに amount を追加すると、C02も2行になります。DISTINCTは「列の組み合わせ」で重複を判定するため、列を増やすほど重複とみなされる行は減ります。これでは「顧客ごとに1行」にはなりません。
GROUP BY:キーごとの集計値を取る
顧客ごとの件数・最新の注文日・合計金額のような集計値がほしい場合は、GROUP BYを使います。
SELECT
customer_id,
COUNT(*) AS order_count,
MAX(order_date) AS last_order_date,
SUM(amount) AS total_amount
FROM orders
GROUP BY customer_id;
GROUP BYを使うときは、次の2点に注意します。
- GROUP BYに書いていない列は、集計関数で囲まないとSELECTに書けません。 多くのデータベースではエラーになります。MySQLは設定によってエラーにならず、どの行から来たか分からない値を返すことがあります。エラーが出なかったことを、正しいことの根拠にしないでください。
- 集計関数は列ごとに別々に計算されます。 たとえば
MAX(order_date)とMAX(amount)を並べると、C01については最新日 2026-05-10 と最大金額 12000 が返ります。しかし、12000は 2026-04-03 の注文の金額です。別々の行の値が1行に混ざるので、「最新の注文の金額」にはなりません。
最新の注文日を求め、その日付の行を元のテーブルから結合して取り出す方法もあります。ただ、この例ではC02の最新日 2026-05-10 に注文が2件あるため、C02が2行返ります。同じ値の行が複数ありうる場合は、次に説明するROW_NUMBERのほうが確実です。
ROW_NUMBER:キーごとに1行だけ、ほかの列も一緒に取る
「顧客ごとに最新の注文を1行ずつ、注文番号・金額・担当者も含めて取りたい」という場合は、ROW_NUMBERで各行に番号を振り、1番の行だけを取り出します。
WITH ranked AS (
SELECT
order_id,
customer_id,
order_date,
amount,
staff,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date DESC, order_id DESC
) AS rn
FROM orders
)
SELECT order_id, customer_id, order_date, amount, staff
FROM ranked
WHERE rn = 1;
各部分の意味は次のとおりです。
PARTITION BY customer_id:重複のキーです。顧客ごとに番号を1から振り直します。ORDER BY order_date DESC, order_id DESC:残す行の基準です。注文日が新しい順、同じ日なら注文番号が大きい順に1、2、3…と番号を振ります。WHERE rn = 1:各顧客の1番目の行だけを残します。
結果は次の3行です。
| order_id | customer_id | order_date | amount | staff |
|---|---|---|---|---|
| 1002 | C01 | 2026-05-10 | 8000 | 鈴木 |
| 1004 | C02 | 2026-05-10 | 7000 | 田中 |
| 1005 | C03 | 2026-03-21 | 3000 | 鈴木 |
ORDER BYには順番を1つに決める列を必ず入れる
ORDER BY order_date DESC だけでは、C02の2件が同じ順位になります。どちらが1番になるかはデータベース次第になり、実行するたびに結果が変わる可能性があります。注文番号のように値が重ならない列を最後に追加して、順番が1つに決まるようにします。
WITH(またはサブクエリ)で囲む理由
ROW_NUMBERのようなウィンドウ関数は、WHERE句より後に計算されます。そのため WHERE ROW_NUMBER() OVER (...) = 1 とは書けません。いったんWITH句(共通テーブル式)かサブクエリで番号付きの結果を作り、外側のSELECTで rn = 1 に絞り込みます。SQL Serverでは、WITHの直前の文がセミコロンで終わっていないとエラーになることがあります。
ROW_NUMBER・RANK・DENSE_RANKの違い
| 関数 | 同じ順位の行 | 1番の行だけを取ったときの件数 |
|---|---|---|
| ROW_NUMBER | 必ず別々の番号になる | キーごとに必ず1行 |
| RANK | 同じ番号になり、その次の番号が飛ぶ | 同じ順位があれば複数行 |
| DENSE_RANK | 同じ番号になり、その次の番号は飛ばない | 同じ順位があれば複数行 |
「同じ日の注文はすべて見たい」ならRANK、「必ず1行に絞りたい」ならROW_NUMBERを選びます。
使っているデータベースで使えるか確認する
ROW_NUMBERは、SQL Server・PostgreSQL・Oracle・MySQL 8.0以降・SQLite 3.25以降など、多くのデータベースで使えます。ただし、古いバージョンや、AccessのSQLのようにウィンドウ関数に対応していない製品もあります。使っている製品とバージョンの公式ドキュメントで、ウィンドウ関数に対応しているかを確認してください。
UNIONとUNION ALL:2つの結果をつなぐときの重複
別々のテーブルや条件から取得した結果を縦につなぐときは、UNIONかUNION ALLを使います。
-- 店舗の顧客とWebの顧客を1つの一覧にする(重複は除く)
SELECT customer_id, customer_name FROM store_customers
UNION
SELECT customer_id, customer_name FROM web_customers;
| 比較項目 | UNION | UNION ALL |
|---|---|---|
| 重複の扱い | つないだ結果全体から、すべての列が同じ行を除く | 除かずにすべて残す |
| 片方の中だけにある重複 | それも除かれる | 残る |
| 処理 | 重複を判定する処理が加わる | 判定しない |
押さえておきたい点は次のとおりです。
- UNIONで除かれるのは、選択したすべての列が同じ行です。考え方はDISTINCTと同じで、キーが同じでも名前の表記が違う行は残ります。
- どちらのテーブルから来た行かを示す列(例:
'店舗' AS source)を追加すると、その列の値が違うため、両方に登録されている顧客は2行とも残ります。出どころを確認したいならUNION ALLを使うと割り切り、重複を除きたいなら出どころの列は付けません。 - 2つのSELECTは列の数をそろえ、対応する列の型も合わせます。結果の列名には、1つ目のSELECTの列名が使われます。
- 期間がまったく重ならない2か月分のデータのように、そもそも重複が起きない組み合わせなら、UNION ALLで十分です。
取得件数で結果を確かめる
重複を除くSQLは、エラーなく動いても意図どおりとは限りません。次の件数を並べて確認します。
1. 元の件数とキーの種類数
SELECT
COUNT(*) AS total_rows,
COUNT(DISTINCT customer_id) AS distinct_customers
FROM orders;
ROW_NUMBERやGROUP BYで「キーごとに1行」にした結果の件数は、原則として distinct_customers と一致します。
ただし、COUNT(DISTINCT 列) はNULLを数えません。一方、GROUP BYとPARTITION BYはNULLを1つのグループとして扱います。キー列にNULLがあると、結果の件数のほうが1件多くなります。件数が1件だけずれるときは、まずNULLを疑ってください。
2. 重複しているキーの一覧
SELECT customer_id, COUNT(*) AS cnt
FROM orders
GROUP BY customer_id
HAVING COUNT(*) > 1
ORDER BY cnt DESC;
どのキーで何件重複しているかが分かります。ここに出たキーをいくつか選び、残った行が基準どおりかを元のデータと見比べます。
3. 結果にキーの重複が残っていないか
ROW_NUMBERで絞り込んだ結果を、もう一度キーで集計します。
WITH ranked AS (
SELECT
customer_id,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date DESC, order_id DESC
) AS rn
FROM orders
)
SELECT customer_id, COUNT(*) AS cnt
FROM ranked
WHERE rn = 1
GROUP BY customer_id
HAVING COUNT(*) > 1;
結果が0行なら、キーごとに1行になっています。
4. UNIONで除かれた件数
UNIONを使う前と後の件数を比べます。
- 1つ目のSELECTの件数+2つ目のSELECTの件数=UNION ALLの件数
- UNION ALLの件数−UNIONの件数=重複として除かれた件数
除かれた件数が想定より多い、または少ない場合は、どの行が重複と判定されたかをGROUP BYとHAVINGで確認します。
重複が残る・除かれすぎるときの確認点
- 大文字・小文字、末尾の空白:同じ文字列とみなすかどうかは、データベースや列の照合順序(collation)の設定によって変わります。比較のルールを確認し、必要ならキーにする式でUPPERやTRIMなどを使ってそろえます。使える関数名と対応バージョンは、データベースごとに確認してください。
- 全角・半角、表記ゆれ:「株式会社」と「(株)」のような違いは、SQLで重複を除いても同じものになりません。表記ゆれの統一は、別の作業として考えます。
- 型の違い:文字列の '001' と数値の 1 のように型が違う列どうしを比べたりつないだりすると、比較の結果が想定と変わることがあります。キー列の型をそろえておきます。
- NULL:DISTINCT・GROUP BY・PARTITION BYは、NULLどうしを同じものとして1つにまとめます。NULLのキーを結果に含めるか、WHERE句で除くかを決めておきます。
まとめ
- 列の組み合わせが完全に同じ行をまとめるだけなら、DISTINCT
- キーごとの集計値がほしいなら、GROUP BY(列ごとの最大値が別々の行から来る点に注意)
- キーごとに1行、ほかの列もそのまま取りたいなら、ROW_NUMBERと順番を1つに決める列
- 結果を縦につなぐなら、重複を除くUNIONか、すべて残すUNION ALL
- どの方法でも、元の件数・キーの種類数・結果の件数を並べて確かめる
