重複行を除去する基本 — SELECT DISTINCT でユニークな一覧を得る
同じ内容の行が何度も現れるテーブルから「種類の一覧」を得たい場面はよくあります。DISTINCT は SELECT で取り出した列の組み合わせが重複する行をまとめ、ユニークな行だけを返します。
-- DISTINCT は「SELECTした列の組み合わせ」で重複を判定する SELECT DISTINCT user_id FROM access_logs; -- user_id の種類だけが残る SELECT DISTINCT user_id, page FROM access_logs; -- (user_id, page) の組で判定
access_logs には同じユーザーの複数回のアクセスが記録されています。一度でも訪問したことのあるユーザーIDの一覧を、重複なく取得してください。
| log_id | user_id | page |
|---|---|---|
| 1 | 101 | /home |
| 2 | 102 | /home |
| 3 | 101 | /mypage |
| 4 | 101 | /home |
| 5 | 103 | /home |
| 6 | 102 | /mypage |
| user_id |
|---|
| 101 |
| 102 |
| 103 |
SELECT DISTINCT -- 重複する user_id をまとめる user_id FROM access_logs ORDER BY user_id; /* 実行順序: 1. FROM access_logs → 全6行を読み込む 2. SELECT user_id → user_id 列だけを射影 3. DISTINCT → 重複する user_id をまとめる 4. ORDER BY user_id → user_id 昇順で出力 */
LEGEND
① 元データ
FROM access_logsaccess_logs テーブル(6行)を読み込みます。user_id 列には 101 が3回、102 が2回と、重複した値が含まれています。| log_id | user_id | page |
|---|---|---|
| 1 | 101 | /home |
| 2 | 102 | /home |
| 3 | 101 | /mypage |
| 4 | 101 | /home |
| 5 | 103 | /home |
| 6 | 102 | /mypage |
SELECT DISTINCT user_id なら user_id だけ、SELECT DISTINCT user_id, page なら (user_id, page) のペアで判定されます。後者は「ユーザーが訪れたユニークなページの組」になり、結果の行数も変わります。SELECT DISTINCT user_id FROM access_logs と SELECT user_id FROM access_logs GROUP BY user_id は同じ結果を返します。集計(COUNTやSUM)を伴わない単純な重複除去なら DISTINCT が簡潔で読みやすく、集計が必要なら GROUP BY を選ぶ、という使い分けが基本です。DISTINCT(user_id) と書く:DISTINCT は関数ではなくキーワードです。SELECT DISTINCT(user_id), page と書いても括弧は単なるグループ化扱いで、結局 user_id と page の両方に DISTINCT が効きます。意図せず (user_id, page) の組で判定され、想定より行数が増えてしまいます。SELECT DISTINCT log_id, user_id のように主キー級の一意な列(log_id)を含めると、組み合わせがすべてユニークになり重複除去がまったく効きません。重複を消したいときは判定したいキー列だけを SELECT するのが鉄則です。重複を除いて数える — COUNT(*) と COUNT(DISTINCT 列) の違い
「アクセス数」と「訪問人数」は別物です。同じ人が何度訪れても訪問人数は1人。COUNT には3つの形があり、それぞれ数える対象が異なります。
COUNT(*) -- 行数(重複もNULLも全部数える) COUNT(user_id) -- user_id が NULL でない行数 COUNT(DISTINCT user_id) -- user_id の種類数(重複を除く)
COUNT(*) は延べアクセス数(PV)、COUNT(DISTINCT user_id) はユニークユーザー数(UU)。同じユーザーの重複アクセスは COUNT(DISTINCT) で1人にまとまります。なお、PV はアクセスログの行数そのものを数えたいため、一般的には COUNT(user_id) ではなく COUNT(*) を使用します。access_logs から、ページごとに「総アクセス数(pv)」と「ユニークユーザー数(uu)」を取得してください。
| log_id | user_id | page |
|---|---|---|
| 1 | 101 | /home |
| 2 | 102 | /home |
| 3 | 101 | /home |
| 4 | 103 | /home |
| 5 | 101 | /products |
| 6 | 102 | /products |
| 7 | 102 | /products |
| page | pv | uu |
|---|---|---|
| /home | 4 | 3 |
| /products | 3 | 2 |
SELECT page, COUNT(*) AS pv, -- 延べアクセス数(行数) COUNT(DISTINCT user_id) AS uu -- 重複を除いた訪問者数 FROM access_logs GROUP BY page ORDER BY page; /* 実行順序: 1. FROM access_logs → 全7行を読み込む 2. GROUP BY page → page ごとに2グループへ集約 3. COUNT(*) → 各グループの行数(PV)を数える 4. COUNT(DISTINCT user_id) → 各グループの user_id の種類数(UU)を数える 5. SELECT page, pv, uu → 3列を射影 6. ORDER BY page → page 昇順で出力 */
LEGEND
① 元データ
FROM access_logsaccess_logs テーブル(7行)を読み込みます。同じ user_id が同じ page に複数回アクセスしている行(重複)が含まれています。| log_id | user_id | page |
|---|---|---|
| 1 | 101 | /home |
| 2 | 102 | /home |
| 3 | 101 | /home |
| 4 | 103 | /home |
| 5 | 101 | /products |
| 6 | 102 | /products |
| 7 | 102 | /products |
COUNT(*) は行の存在を数えます(NULLも含む全行)。COUNT(user_id) は user_id が NULL でない行を数えます。COUNT(DISTINCT user_id) は 重複を除いた値の種類数を数えます。この3つを混同すると、PV・有効行数・UU を取り違える集計ミスにつながります。COUNT(*) より重くなりがちです。大規模データで「おおよそのユニーク数」で良い場合は、近似集計(PostgreSQL の拡張など)を使う選択肢もある、と頭の片隅に置いておくと実務で役立ちます。COUNT(DISTINCT user_id, page) と書けますが、PostgreSQL では複数列を直接渡せず COUNT(DISTINCT (user_id, page)) のようにROW式で囲む必要があります。DBによって構文が違う点に注意しましょう。重複データを検出する — GROUP BY + HAVING COUNT(*) > 1
同じメールアドレスで二重登録されてしまった、といった重複データの検出は実務で頻出します。GROUP BY で同じ値の行をまとめ、HAVING COUNT(*) > 1 で「2件以上あるグループ」だけを残すのが定石です。
GROUP BY email -- email が同じ行をまとめる HAVING COUNT(*) > 1 -- グループの行数が2以上=重複
WHERE は集約前の個々の行を絞り込み、HAVING は集約後のグループを絞り込みます。COUNT(*) のような集計関数は WHERE では使えず、HAVING でのみ使えます。users テーブルで、同じ email が複数回登録されているメールアドレスと、その登録件数(cnt)を検出してください。
| user_id | name | |
|---|---|---|
| 1 | 田中 | tanaka@example.com |
| 2 | 佐藤 | sato@example.com |
| 3 | 田中 | tanaka@example.com |
| 4 | 鈴木 | suzuki@example.com |
| 5 | 佐藤 | sato@example.com |
| 6 | 山田 | yamada@example.com |
| cnt | |
|---|---|
| sato@example.com | 2 |
| tanaka@example.com | 2 |
SELECT email, COUNT(*) AS cnt FROM users GROUP BY email HAVING COUNT(*) > 1 -- 2件以上のグループ=重複のみ残す ORDER BY email; /* 実行順序: 1. FROM users → 行を読み込む 2. GROUP BY email → email ごとにグループ化 3. HAVING COUNT(*) > 1 → 重複グループだけ残す 4. SELECT email, cnt → 2列を射影 5. ORDER BY email → 並び替えて出力 */
LEGEND
① 元データ
FROM usersusers テーブル(6行)を読み込みます。email 列を見ると、tanaka@example.com と sato@example.com がそれぞれ2回登場しており、二重登録が疑われます。| user_id | name | |
|---|---|---|
| 1 | 田中 | tanaka@example.com |
| 2 | 佐藤 | sato@example.com |
| 3 | 田中 | tanaka@example.com |
| 4 | 鈴木 | suzuki@example.com |
| 5 | 佐藤 | sato@example.com |
| 6 | 山田 | yamada@example.com |
HAVING を使います。実行順序は WHERE → GROUP BY → HAVING の順だと覚えると整理しやすいです。ROW_NUMBER() による行単位の番号付けが必要になります。WHERE COUNT(*) > 1 は構文エラーになります。集計値での絞り込みは必ず HAVING に書きます。逆に「集約前の行を絞る条件」(例: 退会済みを除く)は WHERE に書く、と役割を分けて考えましょう。GROUP BY name, birth_date のように複数列でグループ化して判定します。email だけ、name だけと1列で見ると、本来別人を重複扱いしたり、逆に重複を見逃したりします。重複の定義(どの列の組で同一とみなすか)を最初に決めることが重要です。HAVING COUNT(*) > 1 は「データがきれいか」を確かめる健康診断のような定番クエリとして、まず手に馴染ませておきたいパターンです。重複を1件に絞る — ROW_NUMBER() で代表行だけを残す(重複排除)
重複を「検出」した次は「排除」です。重複行のうちどれか1件だけを代表として残したいとき、ROW_NUMBER() が活躍します。重複の判定キーを PARTITION BY、どれを残すかの優先順位を ORDER BY で指定し、rn = 1 の行だけを採用します。
ROW_NUMBER() OVER ( PARTITION BY email -- 重複を判定するキー ORDER BY created_at DESC -- 新しいものを優先(=残す) ) AS rn -- rn=1 が各グループの代表行
customers には同じ email で複数回登録された顧客がいます。email ごとに最新(created_at が新しい)の1件だけを残し、ユニークな顧客一覧を取得してください。
| customer_id | name | created_at | |
|---|---|---|---|
| 1 | tanaka@ex.com | 田中 | 2024-01-10 |
| 2 | sato@ex.com | 佐藤 | 2024-01-12 |
| 3 | tanaka@ex.com | 田中T | 2024-02-05 |
| 4 | suzuki@ex.com | 鈴木 | 2024-01-20 |
| 5 | sato@ex.com | 佐藤S | 2024-03-01 |
| customer_id | name | created_at | |
|---|---|---|---|
| 3 | tanaka@ex.com | 田中T | 2024-02-05 |
| 4 | suzuki@ex.com | 鈴木 | 2024-01-20 |
| 5 | sato@ex.com | 佐藤S | 2024-03-01 |
WITH ranked AS ( SELECT customer_id, email, name, created_at, ROW_NUMBER() OVER ( PARTITION BY email ORDER BY created_at DESC -- 最新が rn=1 になる ) AS rn FROM customers ) SELECT customer_id, email, name, created_at FROM ranked WHERE rn = 1 -- 各 email の代表1行のみ採用 ORDER BY customer_id; /* 実行順序: 1. CTE(ranked) → customers を読み込む 2. ROW_NUMBER() OVER (...) → email ごとに最新順で番号付け 3. FROM ranked → 派生テーブルを参照 4. WHERE rn = 1 → 各 email の最新1件に絞る 5. SELECT ... → 列を射影 6. ORDER BY customer_id → 並び替えて出力 */
LEGEND
① CTE — 元データ
FROM customerscustomers テーブル(5行)を読み込みます。email を重複の判定キーとして見ると、tanaka@ex.com(2件)と sato@ex.com(2件)が重複しています。各重複から最新の1件を選びます。| customer_id | name | created_at | |
|---|---|---|---|
| 1 | tanaka@ex.com | 田中 | 2024-01-10 |
| 2 | sato@ex.com | 佐藤 | 2024-01-12 |
| 3 | tanaka@ex.com | 田中T | 2024-02-05 |
| 4 | suzuki@ex.com | 鈴木 | 2024-01-20 |
| 5 | sato@ex.com | 佐藤S | 2024-03-01 |
PARTITION BY は何を重複とみなすか(判定キー)、ORDER BY は重複のうちどれを残すか(優先順位)を決めます。「最新を残す」なら ORDER BY created_at DESC、「最古を残す」なら ASC、「優先度の高いものを残す」なら任意の評価列で並べます。この2つを設計するだけで重複排除のルールを自由に表現できます。WHERE ROW_NUMBER() OVER(...) = 1 とは書けません。Window関数は SELECT のタイミングで計算されるため、先に CTE やサブクエリで rn を列として作り、その外側で WHERE rn = 1 と絞る2段構えが必要です。RANK() では両方が rn=1 になり重複が残ってしまいます。必ず1件に絞りたいなら ROW_NUMBER を使い、さらに同値を確実に割るために ORDER BY created_at DESC, customer_id DESC のようにタイブレーク列を足すと安全です。DELETE FROM customers WHERE customer_id IN (SELECT customer_id FROM ranked WHERE rn > 1) のように「代表以外(rn>1)」を消します。いきなり DELETE せず、まず SELECT で rn>1 の行を確認してから削除するのが、事故を防ぐ実務の鉄則です。リスト統合の重複制御 — UNION と UNION ALL の使い分け
複数のリストを縦に積み上げるのが集合演算です。UNION は積み上げたうえで重複行を自動的に除去し、UNION ALL は重複もそのまま全件残します。
SELECT email FROM campaign_a UNION -- 重複を除去(DISTINCT相当) SELECT email FROM campaign_b; -- UNION ALL なら重複もそのまま全件残る(除去処理がない分だけ高速)
2つのキャンペーンの応募者メール(campaign_a と campaign_b)を、重複なく1つのリストに統合してください。
| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| sato@ex.com |
| yamada@ex.com |
| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
| tanaka@ex.com |
| yamada@ex.com |
SELECT email FROM campaign_a UNION -- 両リストを統合し、重複を除去 SELECT email FROM campaign_b ORDER BY email; /* 実行順序: 1. SELECT FROM campaign_a → 上側を取得 2. SELECT FROM campaign_b → 下側を取得 3. UNION → 縦に連結し重複除去 4. ORDER BY email → 全体を並べ替え */
LEGEND
① 上のリスト — campaign_a
SELECT email FROM campaign_a1つ目のキャンペーンの応募者メール(3行)です。これと campaign_b を縦に統合していきます。| tanaka@ex.com |
| sato@ex.com |
| suzuki@ex.com |
UNION は連結後に重複除去を行うため、内部でソートやハッシュのコストがかかります。UNION ALL は除去処理をせず全件をそのまま返すため高速です。重複が出ないと分かっている、あるいは重複も残したい場合は UNION ALL を選ぶのがパフォーマンス上のベストプラクティスです。ORDER BY を1回書きます。各 SELECT ごとに ORDER BY を付けることはできません。なお UNION(重複除去)の仲間に、共通部分を取る INTERSECT、差を取る EXCEPT もあり、同じ集合演算ファミリーです。ORDER BY を書くのも構文として不正です。並べ替えは必ず全体の末尾に1回だけ書きましょう。