DISTINCT ON で「グループごとの最新1行」を取る — 組み合わせのユニーク化から代表行の選抜へ
基礎編の複数列 DISTINCT は「(user_id, category) の組み合わせの種類」を返しましたが、実務では「各ユーザーの最新の購入行そのもの」のように、グループごとに代表1行(他の列も含めた行全体)が欲しい場面が頻出します。PostgreSQL の DISTINCT ON はこれを1クエリでエレガントに解決します。
SELECT DISTINCT ON (user_id) -- user_id ごとに「先頭の1行」だけ残す user_id, order_id, purchased_at FROM purchase_logs ORDER BY user_id, purchased_at DESC; -- 並び順が「先頭」を決める
purchase_logs には各ユーザーの購入履歴が時系列で蓄積されています。「各ユーザーの最新の購入行」(order_id・category・amount を含む行全体)を、ユーザーごとに1行ずつ取得してください。
| order_id | user_id | category | amount | purchased_at |
|---|---|---|---|---|
| 1 | 101 | Books | 1500 | 2026-01-10 |
| 2 | 102 | Food | 800 | 2026-01-12 |
| 3 | 101 | Electronics | 3000 | 2026-02-05 |
| 4 | 103 | Books | 1200 | 2026-02-08 |
| 5 | 102 | Electronics | 5000 | 2026-03-01 |
| 6 | 101 | Food | 600 | 2026-03-15 |
| 7 | 103 | Food | 900 | 2026-02-20 |
| user_id | order_id | category | amount | purchased_at |
|---|---|---|---|---|
| 101 | 6 | Food | 600 | 2026-03-15 |
| 102 | 5 | Electronics | 5000 | 2026-03-01 |
| 103 | 7 | Food | 900 | 2026-02-20 |
EXCEPT ALL で「件数の差」まで検出する — 多重集合の引き算で在庫を突合
基礎編の EXCEPT は差集合を返しますが、内部で重複除去が行われるため「左に3件・右に2件」のような数量の差は消えてしまいます。EXCEPT ALL は重複を保持したまま「同じ値1件につき1件だけ打ち消す」多重集合(マルチセット)の引き算を行います。
-- A = {x, x, x, y} / B = {x, y} のとき A EXCEPT B -- → {} 値レベルで x も y も B にある → 全消し A EXCEPT ALL B -- → {x, x} x は 3−1=2 件残る(件数で相殺)
倉庫の棚卸しで、帳簿在庫 system_stock(商品1個 = 1行)と実棚カウント counted_stock を突合します。「帳簿には存在するが実棚で見つからなかった在庫」を、不足している個数分の行数で取得してください。
| stock_id | sku |
|---|---|
| 1 | A001 |
| 2 | A001 |
| 3 | A001 |
| 4 | B002 |
| 5 | B002 |
| 6 | C003 |
| 7 | D004 |
| scan_id | sku |
|---|---|
| 1 | A001 |
| 2 | A001 |
| 3 | B002 |
| 4 | B002 |
| 5 | C003 |
| sku |
|---|
| A001 |
| D004 |
COUNT(*) OVER で重複行を1パスで全取得 — サブクエリ + IN をウィンドウ関数で進化させる
基礎編では「GROUP BY + HAVING のサブクエリ → IN で本体を再検索」という2回スキャンの構成で重複行を取得しました。ウィンドウ関数 COUNT(*) OVER (PARTITION BY …) を使うと、行を畳まずに各行へ「自分と同じキーの行数」を付与でき、テーブル1回の読み取りで同じ結果+重複件数まで得られます。
COUNT(*) OVER (PARTITION BY member_id, class_id) -- GROUP BY と違い行は減らない。各行に「同じ組の行数」が列として付く
WHERE COUNT(*) OVER (…) > 1 はエラーです。サブクエリ(または CTE)で一度列にしてから外側で絞るのが定石です。フィットネスジムの reservations には、システム不具合で同じ会員が同じクラスを二重予約したレコードが混在しています。重複している (member_id, class_id) の組を持つすべての予約行を、重複件数(dup_cnt)付きで取得してください。
| reservation_id | member_id | class_id | reserved_at |
|---|---|---|---|
| 1 | 201 | Y01 | 2026-01-05 |
| 2 | 202 | P02 | 2026-01-06 |
| 3 | 201 | Y01 | 2026-01-07 |
| 4 | 203 | Y01 | 2026-01-08 |
| 5 | 202 | P02 | 2026-01-09 |
| 6 | 202 | S03 | 2026-01-10 |
| reservation_id | member_id | class_id | reserved_at | dup_cnt |
|---|---|---|---|---|
| 1 | 201 | Y01 | 2026-01-05 | 2 |
| 3 | 201 | Y01 | 2026-01-07 | 2 |
| 2 | 202 | P02 | 2026-01-06 | 2 |
| 5 | 202 | P02 | 2026-01-09 | 2 |
INTERSECT を連結して「全期間に共通」を抽出 — 3ヶ月連続アクティブユーザーの特定
基礎編の INTERSECT は2つの集合の共通部分でした。INTERSECT は何段でも連結でき、「A ∩ B ∩ C」のようにすべての集合に共通する要素を積み上げて絞り込めます。リテンション分析の「3ヶ月連続アクティブ」はこの型の代表例です。
SELECT user_id FROM logins WHERE login_month = '2026-01' INTERSECT -- 1月 ∩ 2月 SELECT user_id FROM logins WHERE login_month = '2026-02' INTERSECT -- (1月 ∩ 2月) ∩ 3月 SELECT user_id FROM logins WHERE login_month = '2026-03';
logins には月次のログイン記録が格納されています(同月内の重複ログインあり)。2026年1月・2月・3月の3ヶ月すべてにログインした継続ユーザーの user_id を取得してください。
| login_id | user_id | login_month |
|---|---|---|
| 1 | 101 | 2026-01 |
| 2 | 102 | 2026-01 |
| 3 | 103 | 2026-01 |
| 4 | 101 | 2026-01 |
| 5 | 101 | 2026-02 |
| 6 | 103 | 2026-02 |
| 7 | 104 | 2026-02 |
| 8 | 101 | 2026-03 |
| 9 | 103 | 2026-03 |
| 10 | 105 | 2026-03 |
| 11 | 103 | 2026-03 |
| user_id |
|---|
| 101 |
| 103 |
ROW_NUMBER + DELETE で重複を物理削除 — 最新を残すクレンジングの完成形
基礎編では MIN(id) + GROUP BY で「残す行」を選びました。応用編の総仕上げは、ROW_NUMBER() で削除対象を採番し、DELETE で物理削除する実務のクレンジング本番手順です。「最新を残す」「タイブレークで決定的にする」「削除行を RETURNING で記録する」の3点を一度に押さえます。
ROW_NUMBER() OVER ( PARTITION BY email -- 重複グループ ORDER BY created_at DESC, contact_id DESC -- 残す行が rn=1 になる順 ) AS rn -- rn=1 を残し、rn>1 を削除する
RETURNING で削除行を記録。この手順を省いた重複削除は事故の典型例です。顧客マスタ contacts には同一 email の重複登録が蓄積されています。email ごとに最新(created_at が最大)の1行を残し、それ以外の古い重複行を DELETE してください。削除した行は RETURNING で contact_id・email・created_at を返してください。
| contact_id | name | created_at | |
|---|---|---|---|
| 1 | sato@ex.com | 佐藤 | 2026-01-10 |
| 2 | suzuki@ex.com | 鈴木 | 2026-01-12 |
| 3 | sato@ex.com | 佐藤 | 2026-02-01 |
| 4 | tanaka@ex.com | 田中 | 2026-02-05 |
| 5 | sato@ex.com | 佐藤 | 2026-03-01 |
| 6 | suzuki@ex.com | 鈴木 | 2026-02-20 |
| contact_id | created_at | |
|---|---|---|
| 1 | sato@ex.com | 2026-01-10 |
| 2 | suzuki@ex.com | 2026-01-12 |
| 3 | sato@ex.com | 2026-02-01 |