Get the Representative Row — Retrieve each customer's latest order in one step with DISTINCT ON
In the basic set, 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 |
| 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 |
SELECT DISTINCT ON (customer_id) customer_id, order_id, amount, ordered_at FROM orders ORDER BY customer_id, ordered_at DESC; -- ordering with the latest row first within each customer /* Execution order: 1. FROM orders → read the rows 2. ORDER BY customer_id, ordered_at DESC → sort so the latest row comes first for each customer 3. DISTINCT ON (customer_id) → keep the first row for each customer 4. SELECT ... → project the columns and output the result */
LEGEND
① Source data
FROM ordersRead the 6 rows of the orders table. customer_id=101 has 3 orders and 102 has 2, so multiple orders from the same customer appear. We will extract the latest order for each customer.| 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 |
DISTINCT ON (key) ... ORDER BY key, priority
ASC to keep the oldest or amount DESC to keep the highest amount.rn=1. Choose the ROW_NUMBER method from Basic Q4 when you also need rn=2 or later (for example, the second-newest order or a list of deletion candidates), or when you need portability across databases. The two methods are the abbreviated and general-purpose forms of the same pattern.ORDER BY customer_id, ordered_at DESC, order_id DESC so that a unique column always determines the order and the query is reproducible.DISTINCT ON (customer_id) ... ORDER BY ordered_at DESC makes PostgreSQL return the error “SELECT DISTINCT ON expressions must match initial ORDER BY expressions.” The syntactic rule is to put the key columns first in ORDER BY.List Duplicate Rows Without Collapsing Them — A Duplicate Flag with COUNT(*) OVER (PARTITION BY)
Basic 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 |
| 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 |
WITH flagged AS ( SELECT product_id, product_name, maker, price, COUNT(*) OVER ( PARTITION BY product_name, maker -- composite duplicate key ) AS dup_cnt FROM products ) SELECT product_id, product_name, maker, price, dup_cnt FROM flagged WHERE dup_cnt > 1 -- filter to duplicate rows only ORDER BY dup_cnt DESC, product_id; /* Execution order: 1. CTE(flagged) → read products 2. COUNT(*) OVER (...) → attach each group's count to every row 3. FROM flagged → reference the derived table 4. WHERE dup_cnt > 1 → keep duplicate rows only 5. SELECT ... → project the columns 6. ORDER BY dup_cnt DESC, product_id → sort and output */
LEGEND
① CTE — Source data
FROM productsRead the 6 rows of the products table. By the (product_name, maker) pair, Eraser / ABC Stationery has 3 rows and A5 Notebook / XYZ Paper has 2. The duplicates with different prices (1, 3, 6) are a type of duplicate that DISTINCT cannot remove.| 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 |
COUNT(*) OVER (PARTITION BY key) → WHERE dup_cnt > 1
GROUP BY email HAVING COUNT(*) > 1 shows only which values are duplicated. To see the contents of those duplicate rows, you had tore-JOIN the source table (a self-join)With a window aggregate, you candetect duplicates and preserve the rows in one scanand keep both the query and execution cost simple.PARTITION BY product_name, maker you define which rows count as the same product for business purposes, producing a list that leads to the next cleansing decision:which price is correct.ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) AS rn to obtain both the duplicate list (dup_cnt>1, for investigation) and the deletion-candidate list (rn>1, for removal)from one CTEThis is a core duplicate-management pattern: detection (this question) leads directly to removal (Basic Q4).WHERE COUNT(*) OVER (...) > 1 is a syntax error. Window functions are evaluated after WHERE (at the SELECT stage), so you must first materialize the value in a CTE or subquery and filter it in the outer query. This two-stage structure is required, just as with ROW_NUMBER in Basic Q4.Duplicate Payment Investigation Report — Detect with a Composite Key and Aggregate IDs with STRING_AGG
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 |
| 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 |
SELECT user_id, amount, paid_at, COUNT(*) AS cnt, STRING_AGG(payment_id::TEXT, ', ' ORDER BY payment_id) AS payment_ids FROM payments GROUP BY user_id, amount, paid_at -- define duplicates by the combination of 3 columns HAVING COUNT(*) > 1 -- 2 or more = suspected duplicate payment ORDER BY user_id; /* Execution order: 1. FROM payments → read the rows 2. GROUP BY user_id, amount, paid_at → group by the 3-column combination 3. HAVING COUNT(*) > 1 → keep only duplicate groups 4. COUNT(*) / STRING_AGG(...) → calculate the count and concatenate payment IDs 5. SELECT ... → project the columns 6. ORDER BY user_id → sort and output */
LEGEND
① Source data
FROM paymentsRead the 7 rows of the payments table. user_id=1 has three 5,000-yen payments on the same day, and user_id=3 has two 8,000-yen payments on the same day. user_id=2 has two 3,000-yen payments on different dates, so they are separate legitimate payments.| 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 |
GROUP BY 3 columns → HAVING > 1 → STRING_AGG(id)
ORDER BY payment_id inside the function stabilizes concatenation order and keeps the report reproducible.MIN(payment_id) (the representative to keep), SUM(amount) (the affected amount), and ARRAY_AGG(payment_id) (pass the IDs to a program as an array) in the same SELECT. The idea of putting multiple aggregates on one GROUP BY is the key to consolidating an investigation into one query.2025-05-01 10:23:45.120, millisecond differences put every row in a separate group and no duplicates are detected. For day-level matching use paid_at::DATE; for a repeat payment within 5 minutes, use date_trunc('hour', ...) or a time-difference condition—round the granularity before comparing as a standard practice.LIKE or split, that is a design smell. String aggregation is for final reports that people read. If downstream processing needs rows, keep them as rows with the window method instead of aggregating them.Remove Only Consecutive Duplicates — Extract Change Points with LAG and IS DISTINCT FROM
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 |
| log_id | status | logged_at |
|---|---|---|
| 1 | OK | 09:00 |
| 3 | ERROR | 09:10 |
| 6 | OK | 09:25 |
WITH with_prev AS ( SELECT log_id, status, logged_at, LAG(status) OVER (ORDER BY logged_at) AS prev_status -- previous row's status FROM status_logs ) SELECT log_id, status, logged_at FROM with_prev WHERE prev_status IS DISTINCT FROM status -- different from the previous row = change point only (the first row remains) ORDER BY logged_at; /* Execution order: 1. CTE(with_prev) → read status_logs 2. LAG(status) OVER (...) → attach the previous status 3. FROM with_prev → reference the derived table 4. WHERE prev_status IS DISTINCT FROM status → keep only change points 5. SELECT ... → project the columns 6. ORDER BY logged_at → output in time-series order */
LEGEND
① CTE — Source data
FROM status_logsRead the 7 rows of status_logs. The same status continues as OK twice → ERROR three times → OK twice. We want only the rows where the state changes.| 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 |
LAG(value) OVER (ORDER BY time) → compare with IS DISTINCT FROM
LAG(column, n) retrieves the value n rows earlier (1 when omitted), while LEAD retrieves the value n rows later. The order in OVER (ORDER BY ...) defines “previous” and “next,” so omitting ORDER BY is prohibited. Beyond change-point extraction, this applies to analyses of differences between adjacent rows, such as day-over-day comparisons and time since the previous purchase.= / <>, a NULL on either side yields UNKNOWN and the row is filtered out by WHERE. IS DISTINCT FROM (negated as IS NOT DISTINCT FROM) behaves intuitively: NULL and NULL are equal; NULL and a value are different. It works especially well with LAG's leading NULL and prevents comparison bugs involving NULL.WHERE prev_status <> status makes the comparison for the first row (whose prev_status is NULL) UNKNOWN and silently removes the initial record. This representative NULL-comparison bug is hard to notice because no error is raised. Protect against it with IS DISTINCT FROM or prev_status IS NULL OR prev_status <> status.PARTITION BY when real data contains multiple servers, logs from different servers are compared as “previous rows” and false change points appear. When a series key such as server_id exists, partition by series with LAG(status) OVER (PARTITION BY server_id ORDER BY logged_at).Table Reconciliation and Difference Detection — Validate a Migration with EXCEPT × UNION ALL
Following Basic 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 |
| diff_type | member_id | |
|---|---|---|
| extra | 5 | kato@ex.com |
| missing | 3 | suzuki@ex.com |
SELECT 'missing' AS diff_type, member_id, email FROM ( SELECT member_id, email FROM members_old EXCEPT -- old − new = missing SELECT member_id, email FROM members_new ) AS d1 UNION ALL -- stack the two directional differences (they cannot overlap, so use ALL) SELECT 'extra' AS diff_type, member_id, email FROM ( SELECT member_id, email FROM members_new EXCEPT -- new − old = extra SELECT member_id, email FROM members_old ) AS d2 ORDER BY diff_type, member_id; /* Execution order: 1. d1: EXCEPT → in old but not new (missing) 2. d2: EXCEPT → in new but not old (extra) 3. add the label column → assign missing / extra 4. UNION ALL → concatenate the differences vertically 5. ORDER BY diff_type, member_id → sort and output */
LEGEND
① source — members_old
SELECT member_id, email FROM members_oldThis is the source table with 4 rows. All 4 should exist in the target. We use it as the reconciliation baseline.| member_id | |
|---|---|
| 1 | tanaka@ex.com |
| 2 | sato@ex.com |
| 3 | suzuki@ex.com |
| 4 | yamada@ex.com |
(old EXCEPT new) UNION ALL (new EXCEPT old)
old EXCEPT new alone misses extra rows, and comparing row counts misses cases like this one where missing and extra rows cancel out. Only when both directions of EXCEPT return 0 rows can you say that the two tables are identical. This symmetric checking pattern is worth memorizing.NOT EXISTS, but EXCEPT completes multi-column matching by simply listing all columns to compare in SELECT. It also treats NULL as an equal value without the common NOT IN trap. Use NOT EXISTS when you also need non-key columns such as update timestamps in the result.SELECT email, member_id in a different order, even rows that should match appear as differences (and compatible types may produce no error). For set operations, make the SELECT column lists on both sides identical, character for character.