python.blacktorgroup.com/Duplicate Customers

Finding near-duplicate customers before a mailing goes out.

Ride Me Cycles' contact list picked up the same customer more than once, spelled slightly differently each time. A mail merge doesn't know "J Smith" and "John Smith" are the same person; a script that only checks for an exact match doesn't either.

A translucent black cube etched with a moss-green map of the world's continents, glowing softly against black.
BlackTor Group Ltd Dartmoor, UK
A note on this page

A reference, not a client write-up.

Illustrative, per the note on the main Python page: not real client work. It sits alongside the Product Photos and Nightly Backup pages as a deeper dive on one script that comes up often enough to be worth its own write-up.

The issue

Comparing every record against every other one doesn't scale.

The obvious fix, comparing every customer's name against every other customer's name and flagging a close match, works correctly on paper but scales badly: a straightforward nested comparison over a few thousand customers means several million comparisons, most of them entirely pointless, since a customer in Exeter is never going to be a duplicate of one in Aberdeen.

The fix isn't a cleverer similarity check, it's doing far fewer of them: group customers who could plausibly be the same person first, and only run the expensive comparison inside each group.

Blocking + similarity

find_likely_duplicates(): compare within a postcode, not across the whole list

"""find_duplicate_customers.py Flags likely-duplicate customer records for review before a mailing run. Exact matching misses "J Smith" vs "John Smith" at the same postcode; a full pairwise comparison across every record is O(n^2) and too slow past a few thousand rows. This blocks records by postcode first (customers who don't share a postcode are never compared), then only runs the more expensive name-similarity check within each block. """ from __future__ import annotations from collections import defaultdict from dataclasses import dataclass from difflib import SequenceMatcher from itertools import combinations NAME_SIMILARITY_THRESHOLD: float = 0.82 @dataclass(frozen=True) class CustomerRecord: customer_id: int full_name: str postcode: str def normalise_name(full_name: str) -> str: """Lower-case and collapse whitespace so 'J. Smith' and 'j smith' compare the same way, without touching the original record used for display. """ return " ".join(full_name.lower().replace(".", "").split()) def block_by_postcode(customers: list[CustomerRecord]) -> dict[str, list[CustomerRecord]]: """Group customers by postcode. Two customers who don't share a postcode are never compared at all, which is what keeps this fast: the expensive step below only ever runs inside one block, not across the whole table. """ blocks: dict[str, list[CustomerRecord]] = defaultdict(list) for customer in customers: blocks[customer.postcode].append(customer) return blocks def find_likely_duplicates( customers: list[CustomerRecord], ) -> list[tuple[CustomerRecord, CustomerRecord, float]]: """Return every pair of customers judged a likely duplicate, as (customer_a, customer_b, similarity_score), highest similarity first. Only pairs within the same postcode block are ever compared, so the total number of SequenceMatcher calls stays close to O(n), not O(n^2), for any real customer list, where postcodes are reasonably well spread. """ likely_duplicates: list[tuple[CustomerRecord, CustomerRecord, float]] = [] for postcode_block in block_by_postcode(customers).values(): if len(postcode_block) < 2: continue # nothing in this postcode to compare against for customer_a, customer_b in combinations(postcode_block, 2): similarity_score = SequenceMatcher( None, normalise_name(customer_a.full_name), normalise_name(customer_b.full_name), ).ratio() if similarity_score >= NAME_SIMILARITY_THRESHOLD: likely_duplicates.append((customer_a, customer_b, similarity_score)) likely_duplicates.sort(key=lambda match: match[2], reverse=True) return likely_duplicates

block_by_postcode is the step that actually solves the scaling problem: it costs one pass over the whole customer list, and every comparison that follows happens only inside a block, never across the full table.

itertools.combinations then generates each pair within a block exactly once, so "Smith, J" is never compared against "Smith, J" a second time in reverse order, and normalise_name means a stray full stop or a difference in capitalisation never costs a match that should have been found.

The frozen dataclass gives each customer record a real, self-documenting type instead of a loose tuple or dict, so a typo in a field name fails immediately at the point it's written, not silently somewhere downstream.

Result

A short review list, not three copies of the same seasonal offer.

The script produces a short list of likely duplicates for a human to confirm before a mailing goes out, rather than either missing them or trying to compare every customer against every other one.