Readineer
PATTERN 18SQL Patterns / Analytical & Sequence

SQL deduplication and latest-row problems: keeping the right record

Learn how to remove true duplicates, return the latest record for each entity with ROW_NUMBER, define what makes a row unique, and use deterministic tie-breakers when timestamps alone cannot identify the correct record.

8 min read10 sections2 predictions
01Foundation

Repeated rows are not always duplicates

Seeing the same customer several times in a table does not automatically mean the data contains duplicates. Consider a table that records changes to customer accounts over time:

Customer Updates
update_idcustomer_idcustomer_nameupdated_atstatus
2011Asha2026-07-01 09:00:00active
2021Asha2026-07-10 12:00:00premium
2032Ben2026-07-02 10:00:00active
2042Ben2026-07-20 15:00:00active
2053Carla2026-07-05 08:00:00pending
2063Carla2026-07-05 08:00:00active
2074Dev2026-07-03 11:00:00inactive
2085Esha2026-07-06 16:00:00active

Asha, Ben, and Carla each appear twice, but these rows represent separate updates. For Asha, July 1 -> active and July 10 -> premium are both meaningful. So a repeated customer_id does not automatically mean we should remove one of the rows.

Key idea
Before deduplicating anything, ask: what should one row in the final result represent? That question determines what “duplicate” actually means.

Different meanings of duplicate

The word duplicate can describe several different problems, and they should not automatically be solved with the same SQL:

  • Exact duplicate rows: two rows contain the same values for everything we care about.
  • Repeated entities: the same customer appears several times because their state changed over time.
  • Duplicate events from retries: the same business event may have been ingested more than once.
  • Latest-state problems: several historical records are valid, but the output requires only the most recent one.

A good deduplication query starts by defining which columns identify the thing that should be unique, and, if several rows represent that thing, which one should survive.

02DISTINCT

Removing duplicate result rows with DISTINCT

Suppose the requirement is show every unique status that has been observed for each customer. We do not care when the status occurred:

Query
SELECT DISTINCT
    customer_id,
    customer_name,
    status
FROM customer_updates
ORDER BY
    customer_id,
    status;
Result
customer_idcustomer_namestatus
1Ashaactive
1Ashapremium
2Benactive
3Carlaactive
3Carlapending
4Devinactive
5Eshaactive

Ben had two active updates (203 and 204). Because the selected values are identical (Ben | active), DISTINCT returns that combination once. This is correct because the requirement asks for unique customer-status combinations.

DISTINCT does not mean one row per customer

Now suppose the requirement is return one row per customer showing their current status. Someone tries the same SELECT DISTINCT customer_id, customer_name, status. Asha still appears twice (active and premium), and so does Carla. DISTINCT is working correctly; those are different result rows. The real requirement is not “remove identical rows,” it is “choose one record from several records belonging to the same customer.” That is a different problem.

03Top-1 per group

Latest row per customer

Suppose the requirement is return the most recent update for each customer. We need exactly one row per customer_id, and when several rows belong to the same customer, we need the latest one. This is a Top-1-per-group problem, solved with ROW_NUMBER(). First rank each customer’s rows:

Query
SELECT
    update_id,
    customer_id,
    customer_name,
    updated_at,
    status,
    ROW_NUMBER() OVER (
        PARTITION BY customer_id
        ORDER BY updated_at DESC
    ) AS rn
FROM customer_updates;

SQL creates an independent ranking for every customer. For Asha, 202 (July 10, premium) gets rn = 1 and 201 (July 1, active) gets rn = 2. For Ben, 204 (July 20) gets rn = 1. The newest record gets rn = 1. Window-function results are commonly filtered in an outer query or CTE:

Query
WITH ranked_updates AS (
    SELECT
        update_id,
        customer_id,
        customer_name,
        updated_at,
        status,
        ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY updated_at DESC
        ) AS rn
    FROM customer_updates
)
SELECT
    update_id,
    customer_id,
    customer_name,
    updated_at,
    status
FROM ranked_updates
WHERE rn = 1
ORDER BY customer_id;
PARTITION BY customer_id  Rank each customer's records separately.
ORDER BY updated_at DESC  Newest record first.
ROW_NUMBER()              Give every record a unique position.
WHERE rn = 1              Keep the first record from each customer.

This pattern is widely useful for latest customer status, most recent account balance, latest product price, current address, latest device reading, and most recent application state.

04The featured failure

The latest-row query with an unresolved tie

Look closely at Carla’s records: 205 (pending) and 206 (active) both have exactly the same updated_at of 2026-07-05 08:00:00. Now consider ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC). SQL knows both records are newer than anything else in Carla’s partition, but it does not know which tied row should come first.

PAUSE & PREDICTWhich status is guaranteed to receive rn = 1 for Carla?
Query
ROW_NUMBER() OVER (
    PARTITION BY customer_id
    ORDER BY updated_at DESC
)

-- Carla:
-- 205 | 2026-07-05 08:00:00 | pending
-- 206 | 2026-07-05 08:00:00 | active

-- A: pending    B: active    C: neither is guaranteed    D: both receive rn = 1
Your prediction

The fix: add a deterministic tie-breaker

Suppose the data contract tells us: if two updates have the same timestamp, the larger update_id represents the later accepted update. Then order by updated_at DESC, update_id DESC, so Carla’s 206 (active) gets rn = 1 and 205 (pending) gets rn = 2:

Query
WITH ranked_updates AS (
    SELECT
        update_id,
        customer_id,
        customer_name,
        updated_at,
        status,
        ROW_NUMBER() OVER (
            PARTITION BY customer_id
            ORDER BY
                updated_at DESC,
                update_id DESC
        ) AS rn
    FROM customer_updates
)
SELECT
    update_id,
    customer_id,
    customer_name,
    updated_at,
    status
FROM ranked_updates
WHERE rn = 1
ORDER BY customer_id;
Result
update_idcustomer_idcustomer_namestatus
2021Ashapremium
2042Benactive
2063Carlaactive
2074Devinactive
2085Eshaactive

The important point is not that update_id DESC is always the correct tie-breaker. It is correct only because we defined that business rule. Your system might instead use ingested_at, version_number, event_sequence, source_priority, or transaction_id. The tie-breaker should come from the meaning of the data.

05MAX is not the row

Latest timestamp is not always enough

A common approach is to first calculate MAX(updated_at) per customer with GROUP BY customer_id. This correctly finds the latest timestamp for each customer, but it gives us only customer_id and the latest timestamp; it does not automatically give us status, customer_name, or update_id. So someone may join the result back:

Query
WITH latest_times AS (
    SELECT
        customer_id,
        MAX(updated_at) AS latest_updated_at
    FROM customer_updates
    GROUP BY customer_id
)
SELECT
    u.*
FROM customer_updates AS u
JOIN latest_times AS l
    ON l.customer_id = u.customer_id
   AND l.latest_updated_at = u.updated_at;
This looks reasonable, but Carla has two rows at the same maximum timestamp, so both match. The query returns two “latest” records for Carla (205 | pending and 206 | active). The maximum timestamp is unique as a value; the row associated with that timestamp is not necessarily unique.

This is an important SQL distinction. MAX(updated_at) answers “what is the largest timestamp?” It does not answer “which complete row should represent the latest customer state?” If several rows share that timestamp, MAX cannot choose between them. This is why ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC, update_id DESC) is often a natural solution when exactly one complete row must be chosen.

ROW_NUMBER vs RANK for deduplication

Suppose two latest records tie. ROW_NUMBER() still assigns unique numbers (1, 2), so WHERE rn = 1 returns exactly one row per customer. RANK() can assign 1, 1 to tied rows, so WHERE rank = 1 may return more than one row for a customer. That can be correct when the requirement is “return every record tied for latest,” but wrong when it is “return exactly one current record per customer.”

Exactly one row needed   ->  ROW_NUMBER with deterministic ordering
All tied winners needed  ->  RANK may be appropriate
06Retry duplicates

Removing retry duplicates

Another common deduplication problem appears in event pipelines. Suppose an upstream system retries the same update and produces two rows, 301 and 302, both customer_id 1, 2026-07-10 12:00, premium. If the business has established that these represent the same logical update, we need to define the business key for that event, for example customer_id, updated_at, status. Then:

Query
WITH deduplicated AS (
    SELECT
        *,
        ROW_NUMBER() OVER (
            PARTITION BY
                customer_id,
                updated_at,
                status
            ORDER BY update_id
        ) AS rn
    FROM customer_updates
)
SELECT
    *
FROM deduplicated
WHERE rn = 1;

Here the partition means “rows representing the same logical event,” and the ordering means “which physical copy should survive.”

The deduplication key comes from the business

Do not automatically decide that customer_id is the deduplication key. For historical customer updates, one customer legitimately has many rows. Similarly, order_id might identify one logical order, but an order-history table may legitimately contain several versions of that order. The correct uniqueness rule might be customer_id + event_type + event_time, order_id + version, or source_system + event_id.

Key idea
Before writing SQL, state: two rows are duplicates when __________. If that sentence is unclear, the SQL deduplication rule is probably unclear too.

Deduplication vs latest-state selection

These are related but not identical. Deduplication asks “are these two rows copies of the same logical record?” (for example retry 1 and retry 2), and we usually keep one copy. Latest-state selection asks “several historical rows are valid; which one represents the current state?” (July 1 -> active, July 10 -> premium); neither row is a duplicate, but a current-state report needs only premium. Do not describe all repeated entities as duplicates; historical records can be legitimate while still requiring one latest row in a particular query.

Keeping the earliest row instead

The same pattern works when the requirement asks for the first record. For “return each customer’s first known status,” change the ordering to ORDER BY updated_at ASC, update_id ASC and keep WHERE rn = 1. The pattern is not inherently about “latest.” It is: rank rows within each entity according to the business priority, then keep the required position.

Latest record under an additional condition

Suppose the requirement is “return each customer’s latest active or premium update.” This is not necessarily the same as “find every customer’s latest update, then keep it only if it is active or premium.” The placement of the condition matters. Filtering eligible records before ranking (WHERE status IN ('active', 'premium') inside the CTE) asks “among each customer’s active or premium records, which is latest?” Ranking everything first and filtering after (WHERE rn = 1 AND status IN ('active', 'premium')) asks “what is each customer’s latest record, and is that latest record active or premium?” Those are different questions, and the correct placement comes from the requirement.

07Recognition cues

How to spot deduplication and latest-row logic

Think about deduplication or latest-row logic when the requirement says one row per customer, latest record per account, current status, most recent price, latest event per device, remove duplicate ingestion retries, keep one copy of each logical event, first event per user, or current version of each record.

Watch for these common mistakes:

  • DISTINCT is used when rows differ and one must be chosen.
  • MAX(timestamp) finds a time but not necessarily one unique row.
  • A join back to MAX(timestamp) returns several tied records.
  • ROW_NUMBER orders only by a timestamp that can tie.
  • A tie-breaker is chosen arbitrarily rather than from the business rules.
  • RANK() is used even though exactly one row must survive.
  • The deduplication key is too broad and removes valid history.
  • The deduplication key is too narrow and fails to collapse real retries.
  • Filtering before ranking changes which rows are eligible to become latest.
08A practical mental model

Answer four questions for every deduplication problem

1 · What should one output row represent?

For example one customer, so the partition may begin with PARTITION BY customer_id.

2 · Legitimate history or true duplicates?

Historical versions: choose the record for the required state. Retry duplicates: define which columns identify the same logical event.

3 · Which row should survive?

Latest (updated_at DESC), earliest (updated_at ASC), or first ingested copy (update_id ASC).

4 · Can the main ordering value tie?

If yes, add another rule (updated_at DESC, update_id DESC) so exactly one winner is defined.

Key idea
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC, update_id DESC) reads as: within each customer, put the newest update first, use the larger update ID to resolve equal timestamps, and give every row a unique position. Then WHERE rn = 1 keeps the winner.
09Check your understanding

One more prediction

Consider Carla’s records. The business rule says: return exactly one latest record per customer, and if timestamps tie, the larger update_id wins. Which expression correctly ranks Carla’s rows?

Carla's records
update_idcustomer_idupdated_atstatus
20532026-07-05 08:00:00pending
20632026-07-05 08:00:00active
ONE MORE PREDICTIONWhich expression correctly ranks Carla's rows?
Query
-- Option A
ROW_NUMBER() OVER (
    PARTITION BY customer_id
    ORDER BY updated_at DESC)

-- Option B
RANK() OVER (
    PARTITION BY customer_id
    ORDER BY updated_at DESC)

-- Option C
ROW_NUMBER() OVER (
    PARTITION BY customer_id
    ORDER BY updated_at DESC, update_id DESC)

-- Option D
ROW_NUMBER() OVER (
    ORDER BY updated_at DESC, update_id DESC)
Your prediction
10Summary

Summary

Deduplication is not simply “remove repeated-looking rows.” The first question is what one result row should represent. DISTINCT works when the requirement is genuinely about unique output combinations, but latest-row problems require choosing one row from several valid records, commonly with ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC, update_id DESC) filtered to rn = 1. The important pieces are PARTITION BY (what entity should have one result row), ORDER BY (which record should win), the tie-breaker (what happens when the primary ordering value ties), ROW_NUMBER (give every candidate a unique position), and rn = 1 (keep the winner).

Key idea
Do not assume MAX(updated_at) uniquely identifies a row, and do not assume a timestamp alone is sufficient ordering, because two records can occur at the same timestamp. If exactly one row must survive, the ordering must be strong enough to identify exactly one winner. Finally, separate a true duplicate (two rows represent the same logical record) from a latest-state problem (several rows are legitimate history, but the output requires one current record). Defining which problem you are solving is more important than the SQL technique itself.

Next, we will move to gaps, islands, and streaks, where analytical SQL is used to identify consecutive sequences, breaks between events, and continuous periods of activity.

Go deeper

Ask Colearn about this pattern

Not sure why your latest-row query is unstable, or whether DISTINCT or ROW_NUMBER fits your case? Ask about the concept, or paste a simplified version of your query.

Ask Colearn

3 of 3 free questions left

Instant answers, grounded in the same verified material the diagnostic grades against.

Why does my latest-row query flip between runs?

If two rows share the ordering value (for example the same updated_at), ORDER BY updated_at DESC does not decide which comes first, so ROW_NUMBER assigns rn = 1 arbitrarily and the winner can change between runs.

Add a deterministic tie-breaker that comes from the data's meaning, such as ORDER BY updated_at DESC, update_id DESC, so exactly one winner is defined.

from the unit →
Why does joining back to MAX(updated_at) return two rows?

MAX(updated_at) answers 'what is the largest timestamp', not 'which row is the latest'. If two rows share that maximum timestamp, both match the join, so you get more than one 'latest' record.

The maximum timestamp is unique as a value, but the row containing it may not be. Use ROW_NUMBER with a deterministic tie-breaker when exactly one complete row must be chosen.

from the unit →
Isn't DISTINCT enough to get one row per customer?

No. DISTINCT removes identical result rows. If a customer has different statuses (active, premium), those are different rows, so DISTINCT keeps both.

One row per customer means choosing one record from several that belong to the same customer, which is a Top-1-per-group problem solved with ROW_NUMBER, not DISTINCT.

from the unit →
How do I collapse retry duplicates of the same event?

Define the business key that identifies one logical event (for example customer_id, updated_at, status), PARTITION BY those columns, order by which physical copy should survive, and keep rn = 1.

Do not assume customer_id alone is the key. A too-broad key removes valid history; a too-narrow key fails to collapse real retries.

from the unit →

That’s the free taste

Two ways to go deeper.

Don’t paste credentials, personal data, or confidential production records.

Related patterns

P19Pattern · 13 minGaps, islands & streaksUse the same PARTITION BY and LAG sequencing to find consecutive activity and sessions.P17Pattern · 12 minLAG, LEAD & value functionsThe same PARTITION BY and deterministic-ordering thinking for row-to-row logic.P15Pattern · 10 minWindow functions & rankingThe ROW_NUMBER and top-1-per-group foundation behind latest-row selection.
Up next · Pattern 19
Gaps, islands, and streaks

Next, we move to gaps, islands, and streaks: identifying consecutive sequences, breaks between events, and continuous periods of activity.

Continue to P19 →Browse all patterns