DEV Community

Cover image for How to deduplicate a customer list in Python when no two rows are exactly the same
Hanako H.
Hanako H.

Posted on

How to deduplicate a customer list in Python when no two rows are exactly the same

Removing duplicate rows sounds like set(rows). On a real customer list it rarely is, because the same person signed up twice almost never produces two identical rows. The order date differs, the city changed, the email was typed in capitals the second time. Whether those rows count as duplicates is not something the data can tell you. It depends on the key you compare, and that choice decides everything else: how many rows go, which one stays, and what you lose.

Below, the decisions are made one at a time in plain Python on a 412-row example list, and checked against the Remove Duplicates command in the Spreadsheet Editor of the osFoundry desktop app, which exposes the same choice as a single text field.

The list

The list is generated for the demo: four columns (Email, Name, City, Last order), 374 different customers, and 38 extra rows that repeat an earlier customer's email. Every repeat has its own order date, and for every third one the city was drawn again, the way a second sign-up after a move would look. Counted by email, 31 customers appear more than once: 25 of them twice, 5 three times, 1 four times.

import openpyxl

ws = openpyxl.load_workbook("en-customers.xlsx").active
header, *rows = [tuple(str(c) for c in r) for r in ws.iter_rows(values_only=True)]
len(rows)  # 412
Enter fullscreen mode Exit fullscreen mode

One function, the key is the argument

Every variant below is the same loop with a different key. It keeps the first row it sees for each key, or, with keep="last", overwrites it in place:

def dedupe(rows, key, keep="first"):
    seen, out = {}, []
    for r in rows:
        k = key(r)
        if k in seen:
            if keep == "last":
                out[seen[k]] = r
            continue
        seen[k] = len(out)
        out.append(r)
    return out
Enter fullscreen mode Exit fullscreen mode

A dict lookup per row, so it's linear and keeps the original order of the rows it keeps. pandas does the same with df.drop_duplicates(subset=[...], keep="first").

Whole row or a key

Three keys on the same 412 rows:

key rows kept rows removed
whole row 412 0
Email 374 38
Email + City 387 25

The whole-row key removes nothing. That is the default of most "remove duplicates" buttons, and on a list like this one it silently does no work, because no repeat shares its order date with the original. Email alone removes all 38. Email plus City removes 25 and keeps 13 rows that are the same person in a different city, which is right for a list of delivery addresses and wrong for a list of people.

The Spreadsheet Editor puts this choice into one field of its Remove Duplicates dialog, "Columns (blank = all, e.g. A,C)". Blank is the whole-row key. A is the email key.

The Remove Duplicates dialog in the Spreadsheet Editor with column A as the key

Run with A, the status bar count on column A goes from 413 to 375, which is 374 emails plus the heading, the same number the Python version keeps.

Which copy survives

Keeping the first row is the convention in Excel, in pandas and in this editor, and it is arbitrary as far as your data is concerned. On this list the first row is not the customer's most recent order for 20 of the 31 repeated customers, and 12 of the 31 have copies that disagree on the city. After a first-row dedupe, those 12 keep whichever city was typed first.

If the newest row should win, sort before deduplicating. Python's sort is stable and ISO dates sort correctly as strings, so this is all it takes:

latest = dedupe(sorted(rows, key=lambda r: r[3], reverse=True), key=lambda r: r[0])
len(latest)  # 374, each with its latest order
Enter fullscreen mode Exit fullscreen mode

In a spreadsheet the equivalent is sorting by the date column, newest first, before running Remove Duplicates.

Normalize the key, not the data

The example list has clean emails. Lists filled by people don't. To see what an exact comparison misses, the 38 repeat rows were rewritten in a copy of the list, cycling through five ways a second sign-up gets typed: all capitals, a leading space, a trailing space, a capitalized first letter, and the @ and . typed in full width by an input method (@, .). The first copy of each customer was left as it was.

import unicodedata

def email_key(e):
    return unicodedata.normalize("NFKC", e).strip().lower()
Enter fullscreen mode Exit fullscreen mode
key rows kept rows removed
exact 409 3
strip().lower() 381 31
NFKC + strip().lower() 374 38

The exact key finds 3, and those are only later copies of the same customer that happened to be typed the same way as each other. strip().lower() catches everything except the full-width punctuation, which looks almost identical on screen. NFKC folds the full-width forms into ASCII and gets all 38.

The normalized value is only the key. The row that stays keeps the email exactly as it was written, which matters if the list goes back into a system that stores it.

Two cautions. The part before the @ is case-sensitive by the letter of RFC 5321, so lowercasing it is a practical assumption that holds for the big providers, not a rule. And provider-specific tricks, such as ignoring dots in Gmail addresses, merge addresses that other providers treat as different people; they belong in a separate, deliberate step.

What normalization costs

Building a set of keys from 1,000,000 emails (the example list repeated), Python 3.12 on an Apple M1, median of 5 runs:

key time
raw string 5 ms
strip().lower() 80 ms
NFKC + strip().lower() 102 ms

The normalization is where the time goes, and it is still a tenth of a second per million rows. There is no reason to skip it for speed.

The order that works

Highlight first, then remove. Highlighting duplicates on the key column (in the editor, Conditional Formatting → Highlight Cells Rules → Duplicate Values, which marks the first copy too) shows you the groups before anything is deleted, and that is where you catch the shared family address that shouldn't be merged. Then pick the key, decide which copy wins and sort for it, normalize the key if people typed the data, and check the count afterwards.

The scripts behind every number here use only openpyxl and the standard library. The Spreadsheet Editor runs on your own machine, as the page on osFoundry's local-first design explains, and the app is free from the osFoundry download page.

Top comments (2)

Collapse
 
csvtriage profile image
Lv Shao Mu •

The key-selection point is the important part, and keeping the original email in the surviving row is exactly right. One caveat I'd add to the NFKC step: it is a compatibility normalization, not just a width cleanup, so it can collapse characters that two source systems might treat as different identifiers.

That matters most with email addresses. The domain part is case-insensitive and can be normalized deliberately; the local part before @ is technically case-sensitive under SMTP, even though most providers ignore case. Applying NFKC().strip().lower() to the whole address is a practical rule for a known source/provider, but it is not a universal identity rule. NFKC can fold more than full-width @ and . — ligatures and other compatibility characters can collapse too.

A useful guard before deleting anything is to report normalization collisions: groups where the normalized key is equal but the original strings differ. Keep the raw value, show the collision group, and only auto-merge when the source system's account policy says those values identify the same person. For a billing/customer import, a false merge is generally worse than leaving a possible duplicate for review.

So I'd treat the normalized key as a candidate match key, not proof of identity: raw exact match → auto-dedupe; normalized-only match → review or apply a documented source-specific policy. That preserves the speed benefit without silently combining two accounts that only look equivalent under Unicode compatibility rules.

Collapse
 
csvtriage profile image
Lv Shao Mu •

The "whole row removes nothing" row is the one I would frame and put on a wall. It is the default of every Remove Duplicates button, and on a list where each repeat carries its own timestamp it silently does zero work while the user believes the problem is handled.

Two things I would add from having been bitten by both.

First, strip().lower() being almost enough is the trap, and your NFKC table shows exactly why. The failure mode is that 381 looks like a good number — it removed 31 of 38 — so nobody investigates the remaining 7. The full-width @ and . are visually identical at normal zoom, so the rows look like they should have matched. My rule now: a dedupe result is only trustworthy if you can state the key as a function and show the count it produces on a fixture where you already know the answer. Your 412-row fixture with a known 374 is the right shape for that.

Second, on keep="last" via overwrite-in-place: it preserves the position of the first occurrence while replacing the payload. That is usually what you want, but it is a silent decision. If anyone downstream relies on rows being ordered by Last order descending after the dedupe, they will be wrong, because the surviving row sits at the earlier index with the later date. sorted(...) then dedupe gives the opposite guarantee. Worth pinning in a test.

On the sort-then-dedupe example: ISO dates sorting correctly as strings is true, but only while the column is genuinely ISO. The moment one row is 2026-11-7 instead of 2026-11-07, string sort puts it in the wrong place and the "latest" row you keep is not the latest. Sorting on a parsed date is the safer habit, and it costs one datetime.fromisoformat per row.

The RFC 5321 note is well placed — lowercasing the local part is a practical assumption, not a rule, and saying so out loud is what separates this from the usual set(rows) post.

More of my notes on this kind of failure are linked from my profile.