Remove Duplicate Rows in pandas
drop_duplicates() looks like a one-liner and quietly does the wrong thing on real extracted data:
>>> len(frame)
12_480
>>> len(frame.drop_duplicates())
12_480 # nothing removed, yet the report double-counts
The rows are duplicates in every sense a human cares about. They differ by a trailing space in a supplier name, by a timestamp captured at extraction time, or by case — and exact comparison treats each difference as a distinct record.
The opposite failure is worse:
>>> len(frame.drop_duplicates(subset=["invoice_no"]))
9_204 # 3,276 rows gone — including legitimate multi-line invoices
Root Cause
drop_duplicates compares whole rows by exact value across every column, unless told otherwise. Two things break that.
Columns that are not part of a record's identity — an extraction timestamp, a source filename, a row number — differ on every copy, so no two rows are ever equal. And values that are part of the identity carry incidental variation: Acme Ltd, acme ltd and Acme Ltd are three distinct strings and one supplier.
The over-deletion case is the mirror image: choosing a subset that is not actually unique. An invoice number is unique per invoice, not per line item, so deduplicating on it alone collapses every multi-line invoice to its first row.
Minimal Diagnostic
Count duplicates under several definitions before removing anything.
# pip install "pandas>=2.2,<3"
from pathlib import Path
import pandas as pd
CSV = Path("data/extracted_invoices.csv")
def duplicate_report(frame: pd.DataFrame, identity: list[str]) -> pd.DataFrame:
"""Compare duplicate counts under exact, subset and normalised matching."""
missing = [column for column in identity if column not in frame.columns]
if missing:
raise KeyError(f"identity column(s) not in the frame: {missing}")
normalised = frame[identity].apply(
lambda s: s.astype(str).str.strip().str.casefold() if s.dtype == object else s
)
return pd.DataFrame([
{"definition": "exact, all columns", "duplicates": int(frame.duplicated().sum())},
{"definition": f"subset {identity}", "duplicates": int(frame.duplicated(subset=identity).sum())},
{"definition": "normalised subset", "duplicates": int(normalised.duplicated().sum())},
])
if __name__ == "__main__":
data = pd.read_csv(CSV)
print(duplicate_report(data, ["invoice_no", "line_no"]).to_string(index=False))
definition duplicates
exact, all columns 0
subset ['invoice_no', 'line_no'] 412
normalised subset 1,208
The gap between the second and third rows is the whitespace-and-case problem, quantified. The gap between the first and second is the metadata problem.
The Fix: Normalise, Then Deduplicate on Identity
# pip install "pandas>=2.2,<3"
from pathlib import Path
import pandas as pd
IDENTITY = ["invoice_no", "line_no"]
METADATA = ["extracted_at", "source_file", "_row"]
def normalise_keys(frame: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
"""Add normalised comparison keys without altering the original values."""
out = frame.copy()
for column in columns:
if out[column].dtype == object:
out[f"_key_{column}"] = (
out[column].astype(str)
.str.replace(" ", " ", regex=False) # non-breaking space from Excel
.str.strip()
.str.replace(r"\s+", " ", regex=True)
.str.casefold()
)
else:
out[f"_key_{column}"] = out[column]
return out
def deduplicate(frame: pd.DataFrame, identity: list[str],
keep: str = "last", sort_by: str | None = None
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Return (unique rows, removed rows) matching on normalised identity columns."""
working = normalise_keys(frame, identity)
if sort_by:
working = working.sort_values(sort_by) # decide WHICH copy 'last' means
keys = [f"_key_{column}" for column in identity]
is_duplicate = working.duplicated(subset=keys, keep=keep)
removed = working.loc[is_duplicate].drop(columns=keys)
kept = working.loc[~is_duplicate].drop(columns=keys)
return kept.reset_index(drop=True), removed.reset_index(drop=True)
if __name__ == "__main__":
data = pd.read_csv(Path("data/extracted_invoices.csv"))
unique, dropped = deduplicate(data, IDENTITY, keep="last", sort_by="extracted_at")
print(f"{len(unique):,} kept, {len(dropped):,} removed")
if not dropped.empty:
dropped.to_csv("out/quarantine/duplicates.csv", index=False)
Three decisions are explicit here rather than implicit. The comparison keys are added rather than applied in place, so the original values — the ones a human will read — keep their real spelling. Sorting before deduplicating makes keep="last" mean something specific: the most recently extracted copy, not whichever the file happened to list last. And the removed rows are returned rather than discarded, so the count can be reconciled and the evidence kept — the same discipline as quarantine invalid rows in a pipeline.
Variant: Keeping the Most Complete Copy
keep="first" and keep="last" are positional. When duplicates differ in completeness — one copy has a supplier reference and the other does not — keep the richest.
# pip install "pandas>=2.2,<3"
import pandas as pd
def keep_most_complete(frame: pd.DataFrame, identity: list[str]) -> pd.DataFrame:
"""Within each duplicate group, keep the row with the fewest missing values."""
scored = frame.assign(_filled=frame.notna().sum(axis=1))
return (
scored.sort_values("_filled", ascending=False)
.drop_duplicates(subset=identity, keep="first")
.drop(columns="_filled")
.sort_index()
)
For duplicates that are complementary rather than redundant — each copy holding a different subset of the fields — merge them instead of choosing:
# pip install "pandas>=2.2,<3"
def coalesce_duplicates(frame: pd.DataFrame, identity: list[str]) -> pd.DataFrame:
"""Combine duplicate rows field by field, taking the first non-null of each."""
return (
frame.groupby(identity, as_index=False, dropna=False)
.agg(lambda series: series.dropna().iloc[0] if series.notna().any() else pd.NA)
)
Variant: Near-Duplicates That Normalisation Misses
Acme Ltd against Acme Limited, or Gamma Partners LLP against Gamma Partners L.L.P., survive case-folding. Handle them with a canonical form rather than fuzzy scoring, which is slow and unpredictable at scale.
# pip install "pandas>=2.2,<3"
import re
SUFFIXES = {
r"\blimited\b": "ltd",
r"\bincorporated\b": "inc",
r"\bcompany\b": "co",
r"\bpublic limited company\b": "plc",
}
def canonical_name(series: pd.Series) -> pd.Series:
"""Reduce a company name to a comparison form: no punctuation, standard suffixes."""
out = series.astype(str).str.casefold().str.replace(r"[.,'()]", "", regex=True)
for pattern, replacement in SUFFIXES.items():
out = out.str.replace(pattern, replacement, regex=True)
return out.str.replace(r"\s+", " ", regex=True).str.strip()
if __name__ == "__main__":
names = pd.Series(["Acme Limited", "ACME LTD.", "Acme Ltd", "Beta Group"])
print(canonical_name(names).value_counts())
# acme ltd 3
# beta group 1
Keep the canonical form as a separate column, never as a replacement. The moment a canonicalisation rule is wrong — and eventually one will be — you need the original to recover from it.
Variant: Duplicates Introduced by a Merge
A row count that grows after a join means the join key is not unique on one side. That is a fan-out, not a duplicate, and deduplicating afterwards hides the real problem.
# pip install "pandas>=2.2,<3"
merged = invoices.merge(suppliers, on="supplier_id", how="left", validate="many_to_one")
# raises MergeError if suppliers has repeated supplier_id values
validate= is the cheapest guard in pandas: it turns a silent row explosion into an exception naming the offending relationship. The related column-name collisions are covered in fix pandas merge overlapping columns.
Verification
Assert that the identity is genuinely unique afterwards, and that the drop was proportionate.
# pip install "pandas>=2.2,<3"
import pandas as pd
def assert_deduplicated(before: pd.DataFrame, after: pd.DataFrame, removed: pd.DataFrame,
identity: list[str], max_removed_pct: float = 20.0) -> None:
"""Fail on residual duplicates, an unaccounted row, or an implausible drop."""
remaining = after.duplicated(subset=identity).sum()
assert remaining == 0, f"{remaining} duplicate(s) still present on {identity}"
assert len(after) + len(removed) == len(before), (
f"{len(before):,} in, {len(after):,} kept + {len(removed):,} removed "
f"= {len(after) + len(removed):,}"
)
pct = len(removed) / max(len(before), 1) * 100
assert pct <= max_removed_pct, (
f"{pct:.1f}% of rows removed as duplicates — the identity subset may be too narrow"
)
for column in identity:
assert after[column].notna().all(), f"{column}: null values in an identity column"
print(f"{len(after):,} unique row(s), {len(removed):,} removed ({pct:.1f}%)")
if __name__ == "__main__":
assert_deduplicated(data, unique, dropped, IDENTITY)
The percentage ceiling is what catches the over-deletion this page opens with. A deduplication that removes a quarter of the rows is far more likely to have the wrong identity subset than to have found a quarter-duplicate dataset — and the null check on identity columns catches the related trap, where NaN keys compare unequal and duplicates survive silently.
FAQ
Does drop_duplicates keep the first occurrence by default?
Yes, keep="first". That is positional, so sort deliberately first if "first" is meant to carry meaning such as earliest or most complete.
Why do rows with missing keys never deduplicate?NaN does not equal NaN, so two rows with a null identity column are always distinct. Fill or exclude nulls in the identity columns before comparing.
Is groupby().first() an alternative?
It deduplicates and aggregates in one step, but it also reorders and drops the index. drop_duplicates is clearer when you only want to remove rows; use groupby when you genuinely want to combine them.
How slow is deduplication on a large frame?drop_duplicates hashes the subset, so it is roughly linear — a few seconds on ten million rows. Building normalised string keys costs more than the deduplication itself, so restrict normalisation to the identity columns.
Should duplicates be removed before or after validation? After. Validate first so that a row failing on type is quarantined with its reason, then deduplicate the survivors — otherwise a bad row can be the one copy kept. The order is set out in validating document data with schemas.
Deduplicating Across Runs, Not Just Within One
Everything above deduplicates a single frame. A pipeline that ingests a fresh extract every day has a second problem: the same record arriving on consecutive days, which no within-frame check will catch.
The usual answer is a small persisted set of identity hashes with the run that first saw each one. Before appending, hash the identity columns of the incoming rows and drop those already present. That also gives a useful signal for free — a day where ninety per cent of rows are already known usually means the source exported the wrong date range rather than that nothing happened.
Related
- Cleaning Messy CSV Data with pandas — the cleaning workflow this belongs to
- Fix pandas Merge Overlapping Columns — joins that create apparent duplicates
- Fix pandas concat Columns Misaligned — combining files before deduplicating
- Quarantine Invalid Rows in a Pipeline — keeping the rows you remove
- Handling Large CSV Files with Chunking — deduplicating data too large to hold
Part of Cleaning Messy CSV Data with pandas.