Get each group’s latest row with DISTINCT ON — From combination deduplication to representative-row selection
In the basic set, 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
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.
| 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 |
Detect count differences with EXCEPT ALL — Reconcile inventory by subtracting multisets
The basic set 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
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.
| 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 |
Retrieve every duplicate row in one pass with COUNT(*) OVER — Evolve subquery + IN with a window function
The basic set 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
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.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).
| 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 |
Chain INTERSECT to find users common to every period — Identify users active for three consecutive months
The basic set 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';
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.
| 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 |
Physically delete duplicates with ROW_NUMBER + DELETE — The complete latest-row cleansing pattern
The basic set 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
RETURNING. Skipping this procedure is a classic cause of duplicate-cleanup incidents.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.
| contact_id | name | created_at | |
|---|---|---|---|
| 1 | sato@ex.com | Sato | 2026-01-10 |
| 2 | suzuki@ex.com | Suzuki | 2026-01-12 |
| 3 | sato@ex.com | Sato | 2026-02-01 |
| 4 | tanaka@ex.com | Tanaka | 2026-02-05 |
| 5 | sato@ex.com | Sato | 2026-03-01 |
| 6 | suzuki@ex.com | Suzuki | 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 |