Duplicate Data Management — Learn Window Functions, Set Operations, and CTEs in Practice
AdvancedDuplicate data managementWindow functionsSet operationsCTEPostgreSQL Compatible5 questions
QUESTION 6
Get each group’s latest row with DISTINCT ON — From combination deduplication to representative-row selection
DISTINCT ONPostgreSQLRepresentative rowLatest row
Background

In Basic Q6, multi-column DISTINCT returned the distinct combinations of (user_id, category). In practice, we often need the entire representative row for each group—for example, each user’s latest purchase, including all its other columns. PostgreSQL’s DISTINCT ON solves this elegantly in a single query.

SELECT DISTINCT ON (user_id)  -- keep only the first row for each user_id
  user_id, order_id, purchased_at
FROM purchase_logs
ORDER BY user_id, purchased_at DESC;  -- the sort order determines the first row
ORDER BY determines which row to keep:DISTINCT ON keeps the row that comes first in each group after sorting. Therefore, the leading expressions in ORDER BY must match the DISTINCT ON columns, followed by an ordering that puts the row to retain first (DESC for the latest date).
Problem

The purchase_logs table stores each user’s purchase history over time. Retrieve exactly one row per user: the latest purchase row, including order_id, category, and amount.

Table used
▸ purchase_logs
order_iduser_idcategoryamountpurchased_at
1101Books15002026-01-10
2102Food8002026-01-12
3101Electronics30002026-02-05
4103Books12002026-02-08
5102Electronics50002026-03-01
6101Food6002026-03-15
7103Food9002026-02-20

Note: user_id=101 has 3 purchases, while 102 and 103 have 2 each. Keep only the row with the newest purchased_at (101 → order_id=6, 102 → 5, 103 → 7). Output in ascending user_id order.

Expected Output
user_idorder_idcategoryamountpurchased_at
1016Food6002026-03-15
1025Electronics50002026-03-01
1037Food9002026-02-20
QUESTION 7
Detect count differences with EXCEPT ALL — Reconcile inventory by subtracting multisets
EXCEPT ALLMultisetCount differenceInventory reconciliation
Background

Basic Q7 used EXCEPT to return a set difference, but duplicates are removed internally, so a quantity difference such as “three on the left and two on the right” disappears. EXCEPT ALL preserves duplicates and cancels exactly one matching row for each matching value, performing multiset subtraction.

-- A = {x, x, x, y} / B = {x, y}
A EXCEPT B      -- → {}        both x and y exist in B → all removed
A EXCEPT ALL B  -- → {x, x}    two x remain: 3 − 1 = 2
“Does it exist?” and “How many are missing?” are different questions: If you use EXCEPT for inventory or ledger reconciliation where you need to detect a quantity difference, a difference of one and a difference of one hundred are both collapsed to one value. Choose the ALL form whenever quantity matters.
Problem

Reconcile book inventory system_stock (one row per item) with the physical count counted_stock. Retrieve the inventory present in the books but missing from the physical count, with one result row for each missing item.

Tables used
▸ system_stock (book inventory)
stock_idsku
1A001
2A001
3A001
4B002
5B002
6C003
7D004
▸ counted_stock (physical count)
scan_idsku
1A001
2A001
3B002
4B002
5C003

Book inventory is A001×3, B002×2, C003×1, D004×1; the physical count is A001×2, B002×2, C003×1. A001 is short by 1 and D004 by 1, so the result has 2 rows. Output in ascending sku order.

Expected Output
sku
A001
D004
QUESTION 8
Retrieve every duplicate row in one pass with COUNT(*) OVER — Evolve subquery + IN with a window function
Window functionsCOUNT() OVERPARTITION BYAll duplicate rows
Background

Basic Q8 used a two-scan pattern: a GROUP BY + HAVING subquery followed by an IN lookup against the original table. With the window function COUNT(*) OVER (PARTITION BY …), we can attach “the number of rows with my key” to every row without collapsing the rows, obtaining the same result plus the duplicate count in one table read.

COUNT(*) OVER (PARTITION BY member_id, class_id)
-- Unlike GROUP BY, rows are not reduced; each row receives its group count
A window function cannot be written directly in WHERE: Window functions are calculated in the SELECT phase, after WHERE. Therefore WHERE COUNT(*) OVER (…) > 1 is an error. The standard pattern is to calculate the value in a subquery (or CTE), then filter it in the outer query.
Problem

The fitness club’s reservations table contains duplicate bookings caused by a system problem. Retrieve every reservation row whose (member_id, class_id) pair is duplicated, together with the duplicate count (dup_cnt).

Table used
▸ reservations
reservation_idmember_idclass_idreserved_at
1201Y012026-01-05
2202P022026-01-06
3201Y012026-01-07
4203Y012026-01-08
5202P022026-01-09
6202S032026-01-10

The duplicate pairs are (201, Y01) and (202, P02), with 2 rows each and 4 result rows total. Output in ascending member_id, class_id, reservation_id order.

Expected Output
reservation_idmember_idclass_idreserved_atdup_cnt
1201Y012026-01-052
3201Y012026-01-072
2202P022026-01-062
5202P022026-01-092
QUESTION 9
Chain INTERSECT to find users common to every period — Identify users active for three consecutive months
Chained INTERSECTSet operationsRetained usersCommon to N periods
Background

Basic Q9 introduced INTERSECT as the common part of two sets. INTERSECT can be chained any number of times, building a filter for elements common to every set, like A ∩ B ∩ C. “Active for three consecutive months” in retention analysis is a representative use case.

SELECT user_id FROM logins WHERE login_month = '2026-01'
INTERSECT   -- January ∩ February
SELECT user_id FROM logins WHERE login_month = '2026-02'
INTERSECT   -- (January ∩ February) ∩ March
SELECT user_id FROM logins WHERE login_month = '2026-03';
INTERSECT checks “common” with duplicate removal: Multiple logins in one month still count as one set element. This is an existence test—“present at least once in each period”—not a count test. Use INTERSECT ALL or GROUP BY when login counts also matter.
Problem

The logins table stores monthly login records, including duplicate logins within a month. Retrieve the user_id of every continuing user who logged in during all three months: January, February, and March 2026.

Table used
▸ logins
login_iduser_idlogin_month
11012026-01
21022026-01
31032026-01
41012026-01
51012026-02
61032026-02
71042026-02
81012026-03
91032026-03
101052026-03
111032026-03

January is {101,102,103}, February is {101,103,104}, and March is {101,103,105}, so the common users are 101 and 103. Output in ascending user_id order.

Expected Output
user_id
101
103
QUESTION 10
Physically delete duplicates with ROW_NUMBER + DELETE — The complete latest-row cleansing pattern
ROW_NUMBERCTE + DELETEDuplicate deletionRETURNING
Background

Basic Q10 used MIN(id) + GROUP BY to choose the row to keep. The advanced finale is the practical cleansing procedure: number deletion candidates with ROW_NUMBER() and physically delete them. It combines three points: keep the latest row, make the tie-break deterministic, and record deleted rows with RETURNING.

ROW_NUMBER() OVER (
  PARTITION BY email                      -- duplicate group
  ORDER BY created_at DESC, contact_id DESC  -- make the row to keep rn=1
) AS rn   -- keep rn=1 and delete rn>1
DELETE is irreversible—always use this three-part safety set: ① preview the targets with a SELECT using the same condition, ② execute inside a transaction and check the count before COMMIT, and ③ record deleted rows with RETURNING. Skipping this procedure is a classic cause of duplicate-cleanup incidents.
Problem

The customer master contacts contains duplicate registrations for the same email. Keep the newest row for each email (the maximum created_at) and DELETE the older duplicate rows. Return the deleted contact_id, email, and created_at with RETURNING.

Table used
▸ contacts
contact_idemailnamecreated_at
1sato@ex.comSato2026-01-10
2suzuki@ex.comSuzuki2026-01-12
3sato@ex.comSato2026-02-01
4tanaka@ex.comTanaka2026-02-05
5sato@ex.comSato2026-03-01
6suzuki@ex.comSuzuki2026-02-20

sato@ex.com has 3 rows (latest contact_id=5), suzuki@ex.com has 2 (latest 6), and tanaka@ex.com has 1. The rows deleted are contact_id = 1, 2, and 3.

Expected Output
contact_idemailcreated_at
1sato@ex.com2026-01-10
2suzuki@ex.com2026-01-12
3sato@ex.com2026-02-01