Duplicate Data Management — Learn DISTINCT ON, Window Functions, LAG, and EXCEPT in Practice
AdvancedDuplicate data managementDISTINCT ONWindow functions / LAGEXCEPTPostgreSQL Compatible5 questions
QUESTION 1
Get the Representative Row — Retrieve each customer's latest order in one step with DISTINCT ON
DISTINCT ONRepresentative rowORDER BY designPostgreSQL
Background

In Basic Q4, we used CTE + ROW_NUMBER to take one representative row for each key. PostgreSQL also provides DISTINCT ON, which expresses the same operation in a single query. It keeps only the first row for each specified key after the rows are sorted by ORDER BY.

SELECT DISTINCT ON (customer_id)   -- key used to identify duplicates
  customer_id, order_id, ...
FROM orders
ORDER BY customer_id, ordered_at DESC;  -- sort by key → retention priority
ORDER BY determines which row to keep:DISTINCT ON keeps the first row for each key after sorting. Therefore, the leading expressions in ORDER BY must match the DISTINCT ON key, followed by an ordering that puts the row you want to retain first (use DESC for the latest row). ROW_NUMBER's PARTITION BY corresponds to DISTINCT ON's key, and its ORDER BY corresponds directly to the same priority ordering.
Problem

The orders table contains multiple orders for the same customer. Using DISTINCT ON, retrieve only the latest order (the one with the newest ordered_at) for each customer_id.

Table used
▸ orders
order_idcustomer_idamountordered_at
110112002025-04-01
210234002025-04-02
310156002025-04-10
41039802025-04-05
510221002025-04-12
61017802025-04-03

Note: customer_id=101 has 3 orders and 102 has 2. Keep the latest order for each customer (101 → order_id=3, 102 → order_id=5).

Expected Output
customer_idorder_idamountordered_at
101356002025-04-10
102521002025-04-12
10349802025-04-05
QUESTION 2
List Duplicate Rows Without Collapsing Them — A Duplicate Flag with COUNT(*) OVER (PARTITION BY)
Window aggregationDuplicate detectionPARTITION BYCTE
Background

Basic Q3's GROUP BY + HAVING returns only the duplicated values and their counts, collapsing the IDs and other details of the duplicate rows. With the window aggregate COUNT(*) OVER (PARTITION BY ...), you can attach the number of rows sharing the same key to every row without collapsing a single row.

COUNT(*) OVER (PARTITION BY product_name, maker) AS dup_cnt
-- Unlike GROUP BY, the rows stay intact and the aggregate is added as a column
GROUP BY collapses rows; OVER attaches to rows:GROUP BY reduces each group to one row, while an aggregate function OVER (PARTITION BY ...) adds the group aggregate beside every row while preserving all rows. This is the key shift when you want to list the duplicate rows themselves with all columns.
Problem

The product master products contains duplicate registrations. List every row whose (product_name, maker) pair is duplicated, retaining all original columns plus the duplicate count (dup_cnt).

Table used
▸ products
product_idproduct_namemakerprice
1EraserABC Stationery120
2A5 NotebookXYZ Paper300
3EraserABC Stationery150
4Ballpoint PenABC Stationery200
5A5 NotebookXYZ Paper300
6EraserABC Stationery110

Note: Eraser / ABC Stationery is a 3-row duplicate with different prices, while A5 Notebook / XYZ Paper is a 2-row exact duplicate. Exclude the non-duplicate product_id=4 and order the result by descending dup_cnt, then product_id.

Expected Output
product_idproduct_namemakerpricedup_cnt
1EraserABC Stationery1203
3EraserABC Stationery1503
6EraserABC Stationery1103
2A5 NotebookXYZ Paper3002
5A5 NotebookXYZ Paper3002
QUESTION 3
Duplicate Payment Investigation Report — Detect with a Composite Key and Aggregate IDs with STRING_AGG
STRING_AGGComposite keyHAVINGInvestigation report
Background

When the same user makes multiple payments for the same amount on the same day, the suspected duplicate payment is detected with a composite key. STRING_AGG can then aggregate the payment IDs in each duplicate group into one comma-separated cell, turning the result directly into an investigation report.

GROUP BY user_id, amount, paid_at        -- Define identity with a composite key
HAVING COUNT(*) > 1
-- an aggregate function that concatenates group values as text
STRING_AGG(payment_id::TEXT, ', ' ORDER BY payment_id)
STRING_AGG is an aggregate function:Just as SUM adds numbers, STRING_AGG aggregates group values into a single string joined with a delimiter. Cast numeric columns with ::TEXT; placing ORDER BY inside the function also controls concatenation order (it corresponds to MySQL's GROUP_CONCAT).
Problem

Investigate suspected duplicate payments in payments. For groups with duplicate (user_id, amount, paid_at) combinations, return the count (cnt) and the matching payment IDs (payment_ids, comma-separated in ascending order).

Table used
▸ payments
payment_iduser_idamountpaid_at
101150002025-05-01
102230002025-05-01
103150002025-05-01
104380002025-05-02
105230002025-05-03
106150002025-05-01
107380002025-05-02

Note: user_id=1 has 3 payments for the same amount on the same day (101, 103, 106), and user_id=3 has 2 (104, 107). The 2 payments for user_id=2 are not duplicates because their dates differ.

Expected Output
user_idamountpaid_atcntpayment_ids
150002025-05-013101, 103, 106
380002025-05-022104, 107
QUESTION 4
Remove Only Consecutive Duplicates — Extract Change Points with LAG and IS DISTINCT FROM
LAGChange-point extractionIS DISTINCT FROMTime series
Background

When the same value continues through monitoring-log data, you need to remove only rows equal to the immediately preceding value (compress consecutive duplicates), not deduplicate the entire table. DISTINCT would also remove the second OK in “OK → ERROR → OK,” so use LAG to bring in the previous value and compare it.

LAG(status) OVER (ORDER BY logged_at) AS prev_status
-- In time-series order, bring the previous row's status to the current row (the first row is NULL)

WHERE prev_status IS DISTINCT FROM status   -- a “distinct” operator that compares NULL correctly
The NULL comparison trap and IS DISTINCT FROM:The first row's prev_status is NULL. prev_status <> status makes the comparison with NULL UNKNOWN, causing the first row to disappear. IS DISTINCT FROM treats NULL as a value, so NULL IS DISTINCT FROM 'OK' is true and the first row is safely retained.
Problem

The server-monitoring log status_logs records a status every 5 minutes, so identical statuses appear in long runs. Extract only rows where the status changed from the previous row (change points) and compress the log.

Table used
▸ status_logs
log_idstatuslogged_at
1OK09:00
2OK09:05
3ERROR09:10
4ERROR09:15
5ERROR09:20
6OK09:25
7OK09:30

Note: keep 3 rows: log_id=1 (initial), 3 (OK → ERROR), and 6 (ERROR → OK). The OK at log_id=7 is part of the second OK run, but it is excluded because the previous status is also OK. This differs from whole-table deduplication.

Expected Output
log_idstatuslogged_at
1OK09:00
3ERROR09:10
6OK09:25
QUESTION 5
Table Reconciliation and Difference Detection — Validate a Migration with EXCEPT × UNION ALL
EXCEPTSet operationsData reconciliationUNION ALL
Background

Following Basic Q5's UNION (set union), this question uses the set difference EXCEPT.A EXCEPT B returns rows present in A but not B and is central to validating table migrations and synchronization.

SELECT ... FROM members_old
EXCEPT                       -- in old but not new = missing from migration
SELECT ... FROM members_new;
EXCEPT is also part of duplicate management: Like UNION, EXCEPT also determines row identity by the combination of all columns and removes duplicates from its result (use EXCEPT ALL when you need to retain them). Unlike NOT IN, it handles NULL correctly as an equal value, making it the safest form for reconciliation. Use INTERSECT when you need only the common rows.
Problem

The member table was migrated from members_old to members_new. Detect missing rows (in old but not new: missing) and extra rows (in new but not old: extra) in one result labeled with diff_type.

Table used
▸ members_old (source)
member_idemail
1tanaka@ex.com
2sato@ex.com
3suzuki@ex.com
4yamada@ex.com
▸ members_new (target)
member_idemail
1tanaka@ex.com
2sato@ex.com
4yamada@ex.com
5kato@ex.com

Note: member_id=3 (Suzuki) is missing from the migration, while member_id=5 (Kato) is an extra row found only in the target. Use EXCEPT in both directions and combine the results into one report with UNION ALL.

Expected Output
diff_typemember_idemail
extra5kato@ex.com
missing3suzuki@ex.com