Data cleaning and preparation skill 02 of 7

Duplicate detection

A duplicate is defined by a business key, not by every column being identical. This skill makes an AI agent find repeated records, exactly or approximately, choose the key that decides what counts as the same thing, block so fuzzy matching scales, validate the threshold, then keep, merge or flag each one on purpose rather than deleting on sight.

duplicate-detection/SKILL.md, version 1.0.0, about 2,400 words.

Download .mdSkill folder (.zip)

When to use this, and when not to

Use this skill when the same real-world thing appears more than once in a dataset and you need each one to appear once. The triggers are a request to remove duplicates or dedupe, a call to drop_duplicates, a customer or product that shows up twice, totals that doubled after a data load, names or addresses that are the same entity spelled differently, or a record-linkage task that matches records across two sources with no shared key. The work is to define what "the same" means, find the repeats exactly or approximately, then keep, merge or flag them on purpose.

Do not use it when rows doubled because a join multiplied them (that is a grain problem for sql-joins), for gaps in the data (handling-missing-data), for values that are extreme but singular (outlier-detection), or for stacking two datasets on top of each other. The defining feature here is that two or more rows describe one entity and one of them should remain.

The core idea: a duplicate is defined by a business key, not by every column being identical. Decide which columns identify the entity before you remove anything, because the most common dedupe bug is deleting by the wrong definition of "same" (Christen, *Data Matching*, 2012).

Inputs the agent must have before starting

  1. The tool and version, because the pattern differs: pandas uses drop_duplicates, SQL uses ROW_NUMBER() or GROUP BY ... HAVING, and fuzzy matching needs a library.
  2. The business key: which columns decide that two rows are the same entity. This is the single most important input. "Same email", "same order id", or "same name and date of birth" are keys; "every column identical" rarely is.
  3. Whether the duplicates are exact (byte-for-byte after trimming) or near (typos, casing, spacing, formatting, abbreviations), because near duplicates need similarity matching, not equality.
  4. Which record to keep when several match: the most recent, the most complete, or a merge of all of them, and the column that orders them (a timestamp, a completeness score).
  5. Whether removal is allowed at all, and at what scale. Deletion and merging are irreversible, so confirm them, and for large tables plan to block (step 5) so the comparison does not blow up.

If the business key is unknown, ask for it. Deduping on the wrong key either leaves duplicates in place or merges two distinct entities into one, and the second error is worse.

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as duplicate-detection/SKILL.md.

---
name: duplicate-detection
description: >
  Find and resolve duplicate records so that each real-world entity appears once, across pandas, SQL, Python fuzzy-matching libraries and R. Covers defining the business key that decides what counts as the same thing, exact versus near duplicates, normalising text before comparing, blocking to make fuzzy matching scale, choosing and validating a similarity threshold, the keep-first, keep-last and merge strategies, and flagging rather than deleting. Use it when someone says "remove duplicates", "dedupe", "drop_duplicates", "the same customer appears twice", "merge duplicate records", "fuzzy match these names", "record linkage", or "my totals doubled after a load". Do NOT use it for rows that doubled because of a join fan-out (see sql-joins), for missing values (see handling-missing-data), for extreme values (see outlier-detection), or for stacking two tables together.
license: Free to use and adapt under CC BY 4.0, with credit to Afeez Adebayo. Link afeezadebayo.com, https://www.linkedin.com/in/afeez-adebayo/ and this skill's page at https://afeezadebayo.com/skills-md/data/data-cleaning-preparation/duplicate-detection/.
---

# Duplicate detection

## When to use this, and when not to

Use this skill when the same real-world thing appears more than once in a dataset and you need each one to appear once. The triggers are a request to remove duplicates or dedupe, a call to `drop_duplicates`, a customer or product that shows up twice, totals that doubled after a data load, names or addresses that are the same entity spelled differently, or a record-linkage task that matches records across two sources with no shared key. The work is to define what "the same" means, find the repeats exactly or approximately, then keep, merge or flag them on purpose.

Do not use it when rows doubled because a join multiplied them (that is a grain problem for `sql-joins`), for gaps in the data (`handling-missing-data`), for values that are extreme but singular (`outlier-detection`), or for stacking two datasets on top of each other. The defining feature here is that two or more rows describe one entity and one of them should remain.

The core idea: a duplicate is defined by a business key, not by every column being identical. Decide which columns identify the entity before you remove anything, because the most common dedupe bug is deleting by the wrong definition of "same" (Christen, *Data Matching*, 2012).

## Inputs the agent must have before starting

1. The tool and version, because the pattern differs: pandas uses `drop_duplicates`, SQL uses `ROW_NUMBER()` or `GROUP BY ... HAVING`, and fuzzy matching needs a library.
2. The business key: which columns decide that two rows are the same entity. This is the single most important input. "Same email", "same order id", or "same name and date of birth" are keys; "every column identical" rarely is.
3. Whether the duplicates are exact (byte-for-byte after trimming) or near (typos, casing, spacing, formatting, abbreviations), because near duplicates need similarity matching, not equality.
4. Which record to keep when several match: the most recent, the most complete, or a merge of all of them, and the column that orders them (a timestamp, a completeness score).
5. Whether removal is allowed at all, and at what scale. Deletion and merging are irreversible, so confirm them, and for large tables plan to block (step 5) so the comparison does not blow up.

If the business key is unknown, ask for it. Deduping on the wrong key either leaves duplicates in place or merges two distinct entities into one, and the second error is worse.

## The workflow, step by step

### 1. Define the duplicate: choose the business key

Decide the subset of columns that identifies one entity. Two rows are duplicates when they share that key, even if other columns differ, and they are not duplicates when the key differs, however similar they look. Decision rule: never call a blanket "remove all identical rows" until you have confirmed the key, because a stray column such as a load timestamp or a surrogate id will make genuine duplicates look distinct.

### 2. Count before you touch anything

Count exact duplicates on the full row, then count duplicates on the business key alone (`duplicated(subset=key)`). The gap between the two numbers tells you how much of the duplication is hidden behind irrelevant differing columns, and gives you a target to reconcile against after cleaning.

### 3. Normalise before comparing

Many "near" duplicates are exact once trivial differences are removed. Trim leading and trailing whitespace, collapse repeated spaces, unify case, and standardise formats: lowercase emails, strip punctuation and spacing from phone numbers, parse dates to one format, and normalise unicode. After this, re-count on the key; a large share of apparent near duplicates usually becomes exact and can be handled without fuzzy matching.

### 4. Decide exact versus near

If records that refer to the same entity now match the key exactly, use exact deduplication (steps 6 to 7). If the same entity still appears under variant spellings (`"Jon Smith"` and `"John Smith"`, `"12 Oak St"` and `"12 Oak Street"`), you need fuzzy matching or record linkage (step 5). Decision rule: exact dedupe is cheap and safe, so exhaust normalisation and exact matching first, and only reach for fuzzy matching for the residue that genuine spelling variation leaves behind.

### 5. For near duplicates: block, compare, threshold, review

- **Block first.** Comparing every row with every other row is O(n squared) and will not finish on a large table. Group records into blocks by a cheap shared key (the first three letters of a surname, a postcode, a soundex code), and compare only within each block (Christen, 2012). The Python `recordlinkage` toolkit and `dedupe` library implement this index, compare, classify flow.
- **Compare with the right similarity.** Use an edit-distance or Jaro-Winkler score for short names, and a token-based score for multi-word fields like addresses, because a token score is not fooled by word order.
- **Set a threshold, then validate it.** Pick a similarity cut-off, then read the pairs just above and just below it by hand. The borderline band is where false matches and missed matches live, so a threshold you have not inspected is a guess (liken, "near-deduplication").
- **Review before merging.** Treat the matched pairs as candidates, not decisions, especially for identity data.

### 6. Choose the keep strategy, and order before you keep

Keep-first, keep-last or merge. The trap is that `keep="first"` on unsorted data keeps an arbitrary copy, which may be the stale or least complete one. Sort by recency and completeness first, so the row you keep is the best one. To merge, coalesce the non-null fields across the duplicate set so the survivor carries the most information, rather than discarding fields that only one copy held.

### 7. Prefer flag over delete

Add a group id and an `is_duplicate` flag rather than deleting, so the duplication is visible and reversible, and delete only after a human has confirmed. Keep a record of what was removed or merged, with before and after counts.

### 8. Verify

Re-run the key duplicate count and confirm it is zero. Confirm the kept record is the one you intended (recent and complete). Confirm that totals which were doubled now reconcile against a trusted figure. Finally, confirm you have not removed legitimate repeats: two separate orders from one customer, or two real people who share a name, are not duplicates, and an over-broad key will have swallowed them.

## Worked example, end to end

A nightly job double-inserted part of an orders table, and the finance total is doubled. A blanket dedupe removes nothing:

```python
df = df.drop_duplicates()        # removes 0 rows: each copy has a different load_ts
```

Each copy carries a different `load_ts` and surrogate `row_id`, so no two rows are fully identical. Defining the business key as `order_id` tells the true story:

```python
dupes = df.duplicated(subset="order_id").sum()      # 1,200 duplicate order ids
df = (df.sort_values("load_ts", ascending=False)    # newest first
        .drop_duplicates(subset="order_id", keep="first"))
```

Sorting by `load_ts` before `keep="first"` keeps the most recent copy of each order rather than a random one. The finance total now reconciles with the source system.

A separate customers table has the same person as `"John Smith, 12 Oak St"` and `"Jon Smith, 12 Oak Street"`. Normalising the address turns some pairs into exact matches; the rest need fuzzy matching. Blocking on postcode, then scoring names with Jaro-Winkler, flags pairs above 0.92 as matches and queues the 0.85 to 0.92 band for human review, because that band held both a true match (`"Jon"` and `"John"`) and a false one (two different people at a shared block of flats). Nothing is merged automatically.

## Variations you will meet in practice

- **Genuine repeats are not duplicates.** Two real transactions, or two real people with the same name, must survive. If a dedupe collapses them, the key is too broad; add a discriminating column.
- **Cross-source record linkage.** Matching records between two systems with no shared id is the same block, compare, threshold flow, run across the two sources rather than within one.
- **Join fan-out masquerading as duplicates.** Rows that appear duplicated only after a join are a join grain problem, not a dedupe problem. Fix the join (`sql-joins`); deduping the joined result hides the bug and can drop real rows.
- **Merge rather than keep.** When each copy holds a different fragment (one has the phone, another the email), merge by coalescing non-null fields instead of keeping one and losing the rest.
- **Unicode and hidden characters.** Non-breaking spaces, zero-width characters and smart quotes make identical strings compare as different; normalise unicode before matching.

## Templates you can use verbatim

```python
# Exact dedupe on a business key, keeping the most recent, complete record (pandas)
import pandas as pd
key = ["order_id"]
before = len(df)
df = (df.assign(email=df["email"].str.strip().str.lower())      # normalise first
        .sort_values(["load_ts"], ascending=False)
        .drop_duplicates(subset=key, keep="first"))
print(before - len(df), "duplicate rows removed")
assert df.duplicated(subset=key).sum() == 0

# Near-duplicate matching with blocking (rapidfuzz + a block key)
from rapidfuzz import fuzz
pairs = []
for _, block in df.groupby("postcode"):               # block to avoid O(n^2)
    rows = block.to_dict("records")
    for i in range(len(rows)):
        for j in range(i + 1, len(rows)):
            score = fuzz.WRatio(rows[i]["name"], rows[j]["name"])
            if score >= 85:                            # review the 85-92 band by hand
                pairs.append((rows[i]["id"], rows[j]["id"], score))
```

```sql
-- SQL: find duplicates, then keep one row per key (newest wins)
SELECT order_id, COUNT(*) FROM orders GROUP BY order_id HAVING COUNT(*) > 1;  -- find

DELETE FROM orders
WHERE row_id IN (
  SELECT row_id FROM (
    SELECT row_id,
           ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY load_ts DESC) AS rn
    FROM orders
  ) t WHERE t.rn > 1
);   -- confirm with a human before running a DELETE
```

## Failure modes and anti-patterns

1. **Deduping on all columns.** `drop_duplicates()` removes almost nothing because a timestamp or surrogate id differs, while the key is still doubled. Spot it when the row count barely changes but totals stay inflated. Fix: subset to the business key.
2. **An over-broad key.** The key merges distinct entities, so two real people or two real orders collapse into one. Spot it when the count drops far more than expected. Fix: add a discriminating field and review.
3. **Skipping normalisation.** `"john@x.com"` and `" John@X.com"` survive as two records. Spot it when fuzzy matching flags many pairs that differ only by case or spacing. Fix: trim, lowercase and standardise before matching.
4. **Arbitrary keep.** `keep="first"` on unsorted data keeps a random, possibly stale copy. Spot it when the surviving record is missing recent fields. Fix: sort by recency and completeness first, or merge.
5. **Unblocked fuzzy matching at scale.** An all-pairs comparison never finishes on a large table. Spot it when the job hangs or runs out of memory. Fix: block on a cheap shared key, then compare within blocks.
6. **Deleting join fan-out.** Rows duplicated by a join are deleted as if they were data duplicates, removing real rows. Spot it when duplicates appear only after a join. Fix: correct the join grain, do not dedupe the result.

## Quality bar

- The business key is stated explicitly, and duplicate counts are reported on the key before and after.
- Text is normalised (trim, case, format, unicode) before any comparison.
- Exact versus near is chosen deliberately, and near-duplicate matching blocks before it compares.
- Any similarity threshold is chosen, and the borderline band is reviewed by hand, not trusted blind.
- The keep strategy sorts by recency and completeness, or merges to preserve fields; nothing is deleted or merged without confirmation.
- Verification shows zero remaining key duplicates, reconciled totals, and legitimate repeats preserved.

## Data handling and safety

Deduplication works on the most identifying data a dataset holds: names, emails, phone numbers, addresses. Do not send these to a third-party matching service without permission, and do not log raw identity values. A false match has real consequences, because merging two people's records can combine their medical, financial or account histories, so default to flagging near matches for human review and never auto-merge identity records on a similarity score alone. Deletion and merging are irreversible: require explicit human confirmation before a `DELETE` or a merge, keep an audit of what was removed, and work on a copy rather than the raw source. When the records affect health, money, legal standing or employment, a wrong merge can change a decision about a real person, so human review is mandatory and the merge is never fully automated.

## Appendix: tool-specific notes (verify before relying on these)

- **pandas:** `df.duplicated(subset=..., keep="first"|"last"|False)` returns a boolean mask (`keep=False` flags every member of a duplicate set); `df.drop_duplicates(subset=..., keep=...)` removes them. Sort before `drop_duplicates` to control which row survives. `value_counts` and `groupby(key).size()` help locate duplicates.
- **Fuzzy matching (Python):** `rapidfuzz` (fast; `ratio`, `WRatio`, `token_sort_ratio`, Jaro-Winkler) and the older `thefuzz`. For full record linkage, the `recordlinkage` toolkit and the `dedupe` library provide the index (block), compare and classify stages, and active-learning threshold selection.
- **SQL:** find duplicates with `GROUP BY key HAVING COUNT(*) > 1`; keep one row per key with `ROW_NUMBER() OVER (PARTITION BY key ORDER BY recency)` and delete where the number is above 1. Fuzzy matching varies by engine (PostgreSQL `pg_trgm` similarity and `levenshtein`, `fuzzystrmatch`).
- **R:** `duplicated` and `unique` for exact dedupe; `janitor::get_dupes` to inspect; `fuzzyjoin` and `RecordLinkage` for approximate matching.

## Sources

- Christen, *Data Matching: Concepts and Techniques for Record Linkage, Entity Resolution, and Duplicate Detection* (Springer, 2012): the index (blocking), comparison and classification stages, why blocking is required to avoid quadratic comparison, and the trade-off between false matches and missed matches when choosing a threshold. Verified 31 October 2026.
- liken SKILL.md (victoraut/liken-skills): an existing deduplication and record-linkage implementation covering exact and near deduplication, similarity dedupers (tfidf, lsh, jaccard, cosine) with a threshold, and keep strategies. Noted as a rival that this skill improves on by teaching how to choose the business key, how to select and validate the threshold, and blocking for scale, which it leaves out. Verified 31 October 2026.
- pandas documentation, `DataFrame.duplicated` and `DataFrame.drop_duplicates`: the `subset` and `keep` parameters and their `"first"`, `"last"` and `False` behaviour. Verified 31 October 2026.
- PostgreSQL documentation, `pg_trgm` and `fuzzystrmatch`: trigram similarity and Levenshtein distance for in-database fuzzy matching. Verified 31 October 2026.

The workflow, step by step

1. Define the duplicate: choose the business key

Decide the subset of columns that identifies one entity. Two rows are duplicates when they share that key, even if other columns differ, and they are not duplicates when the key differs, however similar they look. Decision rule: never call a blanket "remove all identical rows" until you have confirmed the key, because a stray column such as a load timestamp or a surrogate id will make genuine duplicates look distinct.

2. Count before you touch anything

Count exact duplicates on the full row, then count duplicates on the business key alone (duplicated(subset=key)). The gap between the two numbers tells you how much of the duplication is hidden behind irrelevant differing columns, and gives you a target to reconcile against after cleaning.

3. Normalise before comparing

Many "near" duplicates are exact once trivial differences are removed. Trim leading and trailing whitespace, collapse repeated spaces, unify case, and standardise formats: lowercase emails, strip punctuation and spacing from phone numbers, parse dates to one format, and normalise unicode. After this, re-count on the key; a large share of apparent near duplicates usually becomes exact and can be handled without fuzzy matching.

4. Decide exact versus near

If records that refer to the same entity now match the key exactly, use exact deduplication (steps 6 to 7). If the same entity still appears under variant spellings ("Jon Smith" and "John Smith", "12 Oak St" and "12 Oak Street"), you need fuzzy matching or record linkage (step 5). Decision rule: exact dedupe is cheap and safe, so exhaust normalisation and exact matching first, and only reach for fuzzy matching for the residue that genuine spelling variation leaves behind.

5. For near duplicates: block, compare, threshold, review

  • Block first. Comparing every row with every other row is O(n squared) and will not finish on a large table. Group records into blocks by a cheap shared key (the first three letters of a surname, a postcode, a soundex code), and compare only within each block (Christen, 2012). The Python recordlinkage toolkit and dedupe library implement this index, compare, classify flow.
  • Compare with the right similarity. Use an edit-distance or Jaro-Winkler score for short names, and a token-based score for multi-word fields like addresses, because a token score is not fooled by word order.
  • Set a threshold, then validate it. Pick a similarity cut-off, then read the pairs just above and just below it by hand. The borderline band is where false matches and missed matches live, so a threshold you have not inspected is a guess (liken, "near-deduplication").
  • Review before merging. Treat the matched pairs as candidates, not decisions, especially for identity data.

6. Choose the keep strategy, and order before you keep

Keep-first, keep-last or merge. The trap is that keep="first" on unsorted data keeps an arbitrary copy, which may be the stale or least complete one. Sort by recency and completeness first, so the row you keep is the best one. To merge, coalesce the non-null fields across the duplicate set so the survivor carries the most information, rather than discarding fields that only one copy held.

7. Prefer flag over delete

Add a group id and an is_duplicate flag rather than deleting, so the duplication is visible and reversible, and delete only after a human has confirmed. Keep a record of what was removed or merged, with before and after counts.

8. Verify

Re-run the key duplicate count and confirm it is zero. Confirm the kept record is the one you intended (recent and complete). Confirm that totals which were doubled now reconcile against a trusted figure. Finally, confirm you have not removed legitimate repeats: two separate orders from one customer, or two real people who share a name, are not duplicates, and an over-broad key will have swallowed them.

Worked example, end to end

A nightly job double-inserted part of an orders table, and the finance total is doubled. A blanket dedupe removes nothing:

df = df.drop_duplicates()        # removes 0 rows: each copy has a different load_ts

Each copy carries a different load_ts and surrogate row_id, so no two rows are fully identical. Defining the business key as order_id tells the true story:

dupes = df.duplicated(subset="order_id").sum()      # 1,200 duplicate order ids
df = (df.sort_values("load_ts", ascending=False)    # newest first
        .drop_duplicates(subset="order_id", keep="first"))

Sorting by load_ts before keep="first" keeps the most recent copy of each order rather than a random one. The finance total now reconciles with the source system.

A separate customers table has the same person as "John Smith, 12 Oak St" and "Jon Smith, 12 Oak Street". Normalising the address turns some pairs into exact matches; the rest need fuzzy matching. Blocking on postcode, then scoring names with Jaro-Winkler, flags pairs above 0.92 as matches and queues the 0.85 to 0.92 band for human review, because that band held both a true match ("Jon" and "John") and a false one (two different people at a shared block of flats). Nothing is merged automatically.

Variations you will meet in practice

  • Genuine repeats are not duplicates. Two real transactions, or two real people with the same name, must survive. If a dedupe collapses them, the key is too broad; add a discriminating column.
  • Cross-source record linkage. Matching records between two systems with no shared id is the same block, compare, threshold flow, run across the two sources rather than within one.
  • Join fan-out masquerading as duplicates. Rows that appear duplicated only after a join are a join grain problem, not a dedupe problem. Fix the join (sql-joins); deduping the joined result hides the bug and can drop real rows.
  • Merge rather than keep. When each copy holds a different fragment (one has the phone, another the email), merge by coalescing non-null fields instead of keeping one and losing the rest.
  • Unicode and hidden characters. Non-breaking spaces, zero-width characters and smart quotes make identical strings compare as different; normalise unicode before matching.

Templates you can use verbatim

# Exact dedupe on a business key, keeping the most recent, complete record (pandas)
import pandas as pd
key = ["order_id"]
before = len(df)
df = (df.assign(email=df["email"].str.strip().str.lower())      # normalise first
        .sort_values(["load_ts"], ascending=False)
        .drop_duplicates(subset=key, keep="first"))
print(before - len(df), "duplicate rows removed")
assert df.duplicated(subset=key).sum() == 0

# Near-duplicate matching with blocking (rapidfuzz + a block key)
from rapidfuzz import fuzz
pairs = []
for _, block in df.groupby("postcode"):               # block to avoid O(n^2)
    rows = block.to_dict("records")
    for i in range(len(rows)):
        for j in range(i + 1, len(rows)):
            score = fuzz.WRatio(rows[i]["name"], rows[j]["name"])
            if score >= 85:                            # review the 85-92 band by hand
                pairs.append((rows[i]["id"], rows[j]["id"], score))
-- SQL: find duplicates, then keep one row per key (newest wins)
SELECT order_id, COUNT(*) FROM orders GROUP BY order_id HAVING COUNT(*) > 1;  -- find

DELETE FROM orders
WHERE row_id IN (
  SELECT row_id FROM (
    SELECT row_id,
           ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY load_ts DESC) AS rn
    FROM orders
  ) t WHERE t.rn > 1
);   -- confirm with a human before running a DELETE

Failure modes and anti-patterns

  1. Deduping on all columns. drop_duplicates() removes almost nothing because a timestamp or surrogate id differs, while the key is still doubled. Spot it when the row count barely changes but totals stay inflated. Fix: subset to the business key.
  2. An over-broad key. The key merges distinct entities, so two real people or two real orders collapse into one. Spot it when the count drops far more than expected. Fix: add a discriminating field and review.
  3. Skipping normalisation. "john@x.com" and " John@X.com" survive as two records. Spot it when fuzzy matching flags many pairs that differ only by case or spacing. Fix: trim, lowercase and standardise before matching.
  4. Arbitrary keep. keep="first" on unsorted data keeps a random, possibly stale copy. Spot it when the surviving record is missing recent fields. Fix: sort by recency and completeness first, or merge.
  5. Unblocked fuzzy matching at scale. An all-pairs comparison never finishes on a large table. Spot it when the job hangs or runs out of memory. Fix: block on a cheap shared key, then compare within blocks.
  6. Deleting join fan-out. Rows duplicated by a join are deleted as if they were data duplicates, removing real rows. Spot it when duplicates appear only after a join. Fix: correct the join grain, do not dedupe the result.

Quality bar

  • The business key is stated explicitly, and duplicate counts are reported on the key before and after.
  • Text is normalised (trim, case, format, unicode) before any comparison.
  • Exact versus near is chosen deliberately, and near-duplicate matching blocks before it compares.
  • Any similarity threshold is chosen, and the borderline band is reviewed by hand, not trusted blind.
  • The keep strategy sorts by recency and completeness, or merges to preserve fields; nothing is deleted or merged without confirmation.
  • Verification shows zero remaining key duplicates, reconciled totals, and legitimate repeats preserved.

Data handling and safety

Deduplication works on the most identifying data a dataset holds: names, emails, phone numbers, addresses. Do not send these to a third-party matching service without permission, and do not log raw identity values. A false match has real consequences, because merging two people's records can combine their medical, financial or account histories, so default to flagging near matches for human review and never auto-merge identity records on a similarity score alone. Deletion and merging are irreversible: require explicit human confirmation before a DELETE or a merge, keep an audit of what was removed, and work on a copy rather than the raw source. When the records affect health, money, legal standing or employment, a wrong merge can change a decision about a real person, so human review is mandatory and the merge is never fully automated.

Appendix: tool-specific notes (verify before relying on these)

  • pandas: df.duplicated(subset=..., keep="first"|"last"|False) returns a boolean mask (keep=False flags every member of a duplicate set); df.drop_duplicates(subset=..., keep=...) removes them. Sort before drop_duplicates to control which row survives. value_counts and groupby(key).size() help locate duplicates.
  • Fuzzy matching (Python): rapidfuzz (fast; ratio, WRatio, token_sort_ratio, Jaro-Winkler) and the older thefuzz. For full record linkage, the recordlinkage toolkit and the dedupe library provide the index (block), compare and classify stages, and active-learning threshold selection.
  • SQL: find duplicates with GROUP BY key HAVING COUNT(*) > 1; keep one row per key with ROW_NUMBER() OVER (PARTITION BY key ORDER BY recency) and delete where the number is above 1. Fuzzy matching varies by engine (PostgreSQL pg_trgm similarity and levenshtein, fuzzystrmatch).
  • R: duplicated and unique for exact dedupe; janitor::get_dupes to inspect; fuzzyjoin and RecordLinkage for approximate matching.

Sources

  • Christen, *Data Matching: Concepts and Techniques for Record Linkage, Entity Resolution, and Duplicate Detection* (Springer, 2012): the index (blocking), comparison and classification stages, why blocking is required to avoid quadratic comparison, and the trade-off between false matches and missed matches when choosing a threshold. Verified 31 October 2026.
  • liken SKILL.md (victoraut/liken-skills): an existing deduplication and record-linkage implementation covering exact and near deduplication, similarity dedupers (tfidf, lsh, jaccard, cosine) with a threshold, and keep strategies. Noted as a rival that this skill improves on by teaching how to choose the business key, how to select and validate the threshold, and blocking for scale, which it leaves out. Verified 31 October 2026.
  • pandas documentation, DataFrame.duplicated and DataFrame.drop_duplicates: the subset and keep parameters and their "first", "last" and False behaviour. Verified 31 October 2026.
  • PostgreSQL documentation, pg_trgm and fuzzystrmatch: trigram similarity and Levenshtein distance for in-database fuzzy matching. Verified 31 October 2026.

When to use this skill, and when not to

Use this skill when the same real-world thing appears more than once and you need it to appear once. It fires on requests to remove duplicates or dedupe, a call to drop_duplicates, a customer or product that shows up twice, totals that doubled after a load, or names and addresses that are the same entity spelled differently. Do not use it when rows doubled because a join multiplied them, which is a grain problem for the sql-joins skill, for gaps in the data, or for extreme single values.

Who it is for

Analysts, data and operations engineers, and anyone using an AI agent to clean a customer, product or transaction list before a report, a migration or a mailing. The skill carries the matching rules, so you do not need to know record-linkage theory to get a clean, auditable result.

What is inside

An eight-step workflow with decision rules. It covers defining the business key that decides what counts as the same thing, counting duplicates before you touch them, normalising text before comparing, the exact versus near decision, blocking so fuzzy matching scales, choosing and validating a similarity threshold, the keep-first, keep-last and merge strategies, and flagging rather than deleting. It ends with a quality bar, a safety section and an appendix for pandas, SQL, Python fuzzy libraries and R.

Example prompts

“Remove the duplicate customers from this list.” “My order totals doubled after the import, why?” “Merge these two spellings of the same company.” “Match these records across two systems with no shared id.”

Example output

The skill returns a plan with the key stated, not a blind drop. A shortened version: a blanket drop_duplicates removes nothing because each copy has a different load timestamp; the business key is order_id, which has 1,200 duplicates; I sort by load time so the newest copy survives, keep one row per order_id, and the finance total now reconciles with the source.

What the agent needs from you

The tool and version, the business key that decides two rows are the same entity, whether the duplicates are exact or near, which record to keep when several match (the most recent, the most complete, or a merge), and whether removal is allowed. The skill asks for the key first, because deduping on the wrong one either leaves duplicates or merges two different entities.

How to install and use it

In the Claude apps, download the SKILL.md from this page, put it in a folder named duplicate-detection, and upload that folder. In Claude Code, Codex and other Agent Skills tools, copy the folder into your skills directory. Then ask the agent to find the duplicates in your file and it loads the skill on its own.

How to find and resolve duplicates step by step

Install the skill as duplicate-detection/SKILL.md. Tell the agent the business key, whether matches are exact or fuzzy, and which record should win. The agent counts duplicates on the key, normalises the text, blocks before any fuzzy comparison, scores pairs and reviews the borderline band, keeps the best record by sorting on recency and completeness, and flags rather than deleting until you confirm.

Worked example

Asked to clean a doubled orders table, a blanket dedupe removes nothing because each copy carries a different load timestamp. The skill defines the key as order_id, finds 1,200 duplicate ids, sorts by load time so the most recent copy is kept, and reconciles the finance total. For a customer table where “Jon Smith” and “John Smith” are one person, it normalises the address, blocks on postcode, scores names, and queues the borderline band for review rather than merging automatically.

Situations this skill handles

SituationWhat the skill does
Exact repeats after a double loadDedupes on the business key, keeping the newest or most complete row
Same entity spelled differentlyNormalises, blocks, scores similarity and reviews the borderline band
Records across two systemsRuns blocking and matching across the two sources with no shared id
Each copy holds a different fieldMerges by coalescing non-null fields rather than discarding them
Rows doubled by a joinSends you to sql-joins to fix the grain rather than deleting real rows

What makes this skill different

Most tutorials call drop_duplicates and move on. This skill teaches the two decisions that matter: how to choose the business key so you do not merge two real entities, and how to set and validate a similarity threshold rather than trusting a default. It blocks before matching so it scales past a few thousand rows, and it defaults to flagging near matches for review, because a wrong merge of two people is worse than a missed one.

Common mistakes

Deduping on every column, so near-identical rows survive on a stray timestamp. An over-broad key that merges two different people. Skipping normalisation, so case and spacing hide exact duplicates. An arbitrary keep that discards the most recent record. An unblocked fuzzy match that never finishes. Deleting join fan-out as if it were data. The skill names each with how to spot it and the fix.

Run it after handling-missing-data and before outlier-detection in a cleaning pass. When duplicates turn out to be a join multiplying rows, use sql-joins to fix the grain instead. For what to do once the data is clean, see the data-analysis skills, starting with exploratory-data-analysis.

Frequently asked questions

Choose the business key: the columns that identify one real-world entity, such as an email or an order id. Two rows are duplicates when they share that key, even if other columns differ, and not when the key differs, however similar they look.

Exact deduplication removes rows that match on the key once trivial differences are normalised away. Fuzzy, or near, deduplication matches entities that are spelled differently, such as Jon and John, using a similarity score and a threshold you validate by hand.

Comparing every row with every other row grows with the square of the row count. Block first: group records by a cheap shared key such as a postcode, then compare only within each block. That is what makes near-duplicate matching scale.

Default to flagging, with a group id and an is_duplicate column, and delete only after a human has confirmed. Deletion and merging are irreversible, and a wrong merge can combine two different people into one record.

Sort by recency and completeness before you keep one, so the survivor is the best copy rather than a random one. When each copy holds a different field, merge by coalescing the non-null values instead of keeping one and losing the rest.

No. Rows that appear duplicated only after a join are a grain problem, not a data duplicate. Fix the join with the sql-joins skill; deduping the joined result hides the bug and can drop real rows.

Changelog

1.0.0, 31 October 2026: first release. Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.