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 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.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.
| order_id | customer_id | amount | ordered_at |
|---|---|---|---|
| 1 | 101 | 1200 | 2025-04-01 |
| 2 | 102 | 3400 | 2025-04-02 |
| 3 | 101 | 5600 | 2025-04-10 |
| 4 | 103 | 980 | 2025-04-05 |
| 5 | 102 | 2100 | 2025-04-12 |
| 6 | 101 | 780 | 2025-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).
| customer_id | order_id | amount | ordered_at |
|---|---|---|---|
| 101 | 3 | 5600 | 2025-04-10 |
| 102 | 5 | 2100 | 2025-04-12 |
| 103 | 4 | 980 | 2025-04-05 |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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 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.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).
| product_id | product_name | maker | price |
|---|---|---|---|
| 1 | Eraser | ABC Stationery | 120 |
| 2 | A5 Notebook | XYZ Paper | 300 |
| 3 | Eraser | ABC Stationery | 150 |
| 4 | Ballpoint Pen | ABC Stationery | 200 |
| 5 | A5 Notebook | XYZ Paper | 300 |
| 6 | Eraser | ABC Stationery | 110 |
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.
| product_id | product_name | maker | price | dup_cnt |
|---|---|---|---|---|
| 1 | Eraser | ABC Stationery | 120 | 3 |
| 3 | Eraser | ABC Stationery | 150 | 3 |
| 6 | Eraser | ABC Stationery | 110 | 3 |
| 2 | A5 Notebook | XYZ Paper | 300 | 2 |
| 5 | A5 Notebook | XYZ Paper | 300 | 2 |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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)
::TEXT; placing ORDER BY inside the function also controls concatenation order (it corresponds to MySQL's GROUP_CONCAT).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).
| payment_id | user_id | amount | paid_at |
|---|---|---|---|
| 101 | 1 | 5000 | 2025-05-01 |
| 102 | 2 | 3000 | 2025-05-01 |
| 103 | 1 | 5000 | 2025-05-01 |
| 104 | 3 | 8000 | 2025-05-02 |
| 105 | 2 | 3000 | 2025-05-03 |
| 106 | 1 | 5000 | 2025-05-01 |
| 107 | 3 | 8000 | 2025-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.
| user_id | amount | paid_at | cnt | payment_ids |
|---|---|---|---|---|
| 1 | 5000 | 2025-05-01 | 3 | 101, 103, 106 |
| 3 | 8000 | 2025-05-02 | 2 | 104, 107 |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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
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.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.
| log_id | status | logged_at |
|---|---|---|
| 1 | OK | 09:00 |
| 2 | OK | 09:05 |
| 3 | ERROR | 09:10 |
| 4 | ERROR | 09:15 |
| 5 | ERROR | 09:20 |
| 6 | OK | 09:25 |
| 7 | OK | 09: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.
| log_id | status | logged_at |
|---|---|---|
| 1 | OK | 09:00 |
| 3 | ERROR | 09:10 |
| 6 | OK | 09:25 |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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 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.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.
| member_id | |
|---|---|
| 1 | tanaka@ex.com |
| 2 | sato@ex.com |
| 3 | suzuki@ex.com |
| 4 | yamada@ex.com |
| member_id | |
|---|---|
| 1 | tanaka@ex.com |
| 2 | sato@ex.com |
| 4 | yamada@ex.com |
| 5 | kato@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.
| diff_type | member_id | |
|---|---|---|
| extra | 5 | kato@ex.com |
| missing | 3 | suzuki@ex.com |
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free