kmail.at
← learning

pandas · difficulty ◆◆

pandas drop_duplicates() — one row per key, not one row per event

drop_duplicates() is not the data-loss risk. Silent duplicates are.

Two rows in your export carry the same invoice number and the same 1,200.00. pandas will happily let you sum both — unless you ask it which rows are the same deal.

2026-10-06 · 5 min read

$ df.drop_duplicates()

What it does

drop_duplicates() removes rows that repeat, comparing all columns by default or just the ones you name in subset. keep decides which member of each repeated group survives: 'first' (the default), 'last', or False to delete every member. ignore_index=True hands back a fresh RangeIndex instead of keeping the original labels; inplace=True mutates and returns None. Its sibling duplicated() returns the same verdict as a boolean Series instead of deleting anything, and keep=False there flags every member of a repeated group — which is how you inspect the damage before you commit to it. Duplicate detection looks at values, never at the index: two rows with different index labels but identical values are duplicates.

Why it matters

The expensive bugs I have chased were never nulls — they were rows that arrived twice and got summed. A nightly job that re-runs, a left join that fans out, a monthly export concatenated with the previous month: all of them produce a plausible-looking table whose totals are wrong by exactly the amount that was double-counted. The cost of a false positive is one row; the cost of a missed duplicate is a number in a board deck. So this method has two jobs, and the second is the important one: audit first (duplicated(keep=False) for the count, then the rows themselves), then delete on a key you chose on purpose — never on all columns by accident, because two legitimately identical rows in a ledger are not the same thing as two submissions of one order.

Example

$ import pandas as pd

crm = pd.DataFrame({
    "contact_id": [7, 3, 7, 12, 3, 21],
    "name":       ["Ada", "Bo", "Ada", "Cy", "Bo", "Dee"],
    "revenue":    [1200, 800, 1350, 400, 800, 2100],
    "updated":    pd.to_datetime(["2026-09-01", "2026-09-02", "2026-10-01",
                                  "2026-09-20", "2026-10-04", "2026-09-11"]),
})

crm = crm.drop_duplicates(subset="contact_id", keep="last", ignore_index=True)
print(crm.to_string())
   contact_id name  revenue    updated
0           7  Ada     1350 2026-10-01
1          12   Cy      400 2026-09-20
2           3   Bo      800 2026-10-04
3          21  Dee     2100 2026-09-11

The everyday shape of the call: dedupe on the business key, keep the newest snapshot, reset the index so nothing downstream trips over the gaps. ignore_index does not sort — contact 12 keeps its arrival position, only the labels are rebuilt. The surviving rows for contacts 7 and 3 are the ones with the higher revenue, because this export appends updates instead of overwriting them.

$ sep = pd.DataFrame({"ticket": ["T-91", "T-92", "T-93"],
                     "region": ["North", "South", "North"],
                     "minutes": [45, 90, 45]})
oct_ = pd.DataFrame({"ticket": ["T-91", "T-93", "T-94"],
                     "region": ["North", "North", "West"],
                     "minutes": [45, 45, 120]})
raw = pd.concat([sep, oct_], ignore_index=True)

print("rows:", len(raw), "| exact duplicates:", int(raw.duplicated().sum()))
print(raw[raw.duplicated(keep=False)].to_string())
print(raw.drop_duplicates(ignore_index=True).to_string())
rows: 6 | exact duplicates: 2
  ticket region  minutes
0   T-91  North       45
2   T-93  North       45
3   T-91  North       45
4   T-93  North       45
  ticket region  minutes
0   T-91  North       45
1   T-92  South       90
2   T-93  North       45
3   T-94   West      120

Two monthly exports stacked with concat, and two rows came back twice. keep=False in duplicated() is the audit: it prints all four rows involved, both copies, so you can see which tickets carried over instead of trusting a count. Only then does drop_duplicates() remove the second occurrence — T-91 and T-93 keep their September position, T-94 is genuinely new.

$ ledger = pd.DataFrame({
    "invoice":  ["INV-01", "INV-01", "INV-02", "INV-03", "INV-03"],
    "customer": ["Nord", "Nord", "Nord", "Ost", "Ost"],
    "net":      [1200.0, 1200.0, 480.0, 950.0, 950.0],
})

clean = ledger.drop_duplicates(subset="invoice")
print(f"naive {ledger['net'].sum():.2f} -> real {clean['net'].sum():.2f}"
      f" (overstated by {ledger['net'].sum() - clean['net'].sum():.2f})")
print(clean.to_string(index=False))
naive 4780.00 -> real 2630.00 (overstated by 2150.00)
invoice customer    net
 INV-01     Nord 1200.0
 INV-02     Nord  480.0
 INV-03      Ost  950.0

Why the first line of a revenue report is worth five seconds of suspicion: two re-submitted invoices inflated the total by 2,150.00. subset='invoice' alone makes INV-01 and INV-03 duplicates even though their other columns agree too — pick the key that defines the entity and drop_duplicates() collapses it in one pass, no groupby-sum dance required.

$ snapshots = pd.DataFrame({
    "account":  ["A-10", "A-10", "A-11", "A-11", "A-12"],
    "plan":     ["basic", "pro", "basic", "basic", "pro"],
    "mrr":      [29, 99, 29, 29, 99],
    "taken_at": pd.to_datetime(["2026-07-01", "2026-09-15", "2026-08-01",
                                "2026-09-20", "2026-06-11"]),
})

current = (snapshots.sort_values("taken_at", ascending=False)
                    .drop_duplicates(subset="account", keep="first"))
print(current.to_string(index=False))
account  plan  mrr   taken_at
   A-11 basic   29 2026-09-20
   A-10   pro   99 2026-09-15
   A-12   pro   99 2026-06-11

The current-state pattern: sort newest-first, then keep='first' — same result as keep='last' on an ascending sort, but it reads as what it is. A-10 upgraded basic to pro 99 and A-11 downgraded pro to basic 29; both rows exist in the log, only the newest survives. Ties on taken_at are broken by row order, because the dedupe is stable and never sorts.

$ import pandas as pd
import numpy as np

print("np.nan == np.nan ->", np.nan == np.nan)
gaps = pd.DataFrame({"site": ["A", "A", "B"], "pm25": [12.0, np.nan, np.nan]})
print("NaN rows counted as duplicates:", gaps.duplicated(subset="pm25").tolist())

lists = pd.DataFrame({"tags": [["a", "b"], ["a", "b"], ["c"]], "n": [1, 1, 2]})
print("one column of lists ->", len(lists[["tags"]].drop_duplicates()), "rows")
try:
    lists.drop_duplicates()
except TypeError as e:
    print("two columns of lists -> TypeError:", e)

cols = pd.DataFrame({"a": [1, 1], "b": [9, 9]})
try:
    cols.drop_duplicates(cols="a")
except TypeError as e:
    print("old keyword -> TypeError:", e)
np.nan == np.nan -> False
NaN rows counted as duplicates: [False, False, True]
one column of lists -> 2 rows
two columns of lists -> TypeError: unhashable type: 'list'
old keyword -> TypeError: DataFrame.drop_duplicates() got an unexpected keyword argument 'cols'

Three failure modes worth knowing before you trust a dedupe: NaN is considered equal to itself here even though Python says otherwise; a frame of list cells is fine on one column and dies on two with TypeError: unhashable type: 'list'; and any tutorial older than May 2014 that taught cols= raises TypeError immediately, because the parameter has been subset since 0.14.0.

Common flags

subset="customer_id"
the key that defines a row. A scalar means one column; a list or tuple means several, and since 3.0.0 any iterable of labels works. A generator does not — duplicated() calls len() on it and dies with TypeError: object of type 'generator' has no len().
keep='last'
the one you want for append-only exports, where the newest row sits at the bottom. keep=False is the audit flag: instead of deleting all but one it deletes every member of a repeated group, so on duplicated() it is how you see the whole collision.
ignore_index=True
replaces the surviving labels with a fresh RangeIndex. Shipped for DataFrame in 1.0.0 and for Series in 2.0.0; without it, gaps in the index survive the dedupe and every later positional access quietly means something else.
inplace=True
mutates the frame and returns None — still None for this method on 3.0.3, unlike fillna(). That is exactly why df = df.drop_duplicates(inplace=True) blanks out your table.
df.duplicated(subset=key, keep=False)
the boolean mask instead of the deletion. DataFrame.duplicated takes subset; Series.duplicated does not. Use it for the count, for the evidence rows, then dedupe with the same subset.
hashable cells only
subset columns must hold hashable values. Lists raise TypeError: unhashable type: 'list' as soon as more than one column is involved; tuples are fine, and a single-column selection also works — through a different, far slower code path.
unknown column name
raises KeyError with the offending labels, e.g. KeyError: Index(['zzz'], dtype='str'), so a typo in subset fails loudly instead of handing your frame back untouched.

History

0.6.0, November 2011 — 'probably just use sets'

GitHub issue #319, 'Add new function to remove duplicate rows from a DataFrame', was opened on 2011-11-01 and closed on 2011-11-07 with the note 'should be reasonably performant, probably just use sets + np.apply_along_axis'. It shipped in 0.6.0 on 2011-11-25 — the same release that added duplicated() beside it — with the signature drop_duplicates(col_or_columns=None, take_last=False). Reading the v0.6.0 tree today is a small archaeology lesson: no keep, no inplace, no ignore_index, and keys built by zip(*self.values.T) before the hashtable path arrived in 0.14.0.

Three names for one argument, 2014 to 2026

The column argument has been renamed twice. 0.14.0 (2014-05-31) replaced cols with subset 'to better align with dropna' (GH#6680, filed 2014-03-21, closed four days later) behind a FutureWarning. The keep flag arrived in 0.17.0 (2015-10-09), replacing take_last=True|False with 'first'|'last'|False (GH#6511, GH#8505); take_last lingered as a deprecated alias until 0.20.0 (2017-05-05, GH#10236) deleted it along with the copies on nlargest and nsmallest. Index gained the pair in 0.15.0 (2014-10-18, GH#4060), ignore_index landed in 1.0.0 (2020-01-29, GH#30114), and 3.0.0 (2026-01-21, GH#59237) finally accepted any Iterable[Hashable] — a set of column names, which used to trip the len() call.

Fun facts

Pros & cons

pros

  • + One call replaces a set-based Python loop, a sort-then-drop, or a groupby you must remember to reset: df.drop_duplicates(subset=key, keep='last', ignore_index=True) is the whole fix for a re-run export.
  • + duplicated() exposes the same comparison as a mask, so the count you report and the rows you delete cannot drift apart — both come from one definition of 'duplicate'.
  • + Identical behaviour on DataFrame, Series and Index (Index since 0.15.0), and since 3.0.0 any iterable of labels is accepted, so a set of key columns no longer trips the len() call.

cons

  • − The default compares every column, so a single timestamp or a float that moved by one rounding step keeps two otherwise identical business rows alive; you nearly always want subset= and have to know your primary key to write it.
  • − Failure modes depend on dtype: list cells raise TypeError with several columns but silently take the slow path with one, and string keys never match numeric ones (1 and '1' are not duplicates), so a mixed-type key column quietly fails to dedupe.
  • − inplace=True returns None for this method on 3.0.3, so the reassignment idiom that works for fillna() blanks out your DataFrame here — the two methods now differ on a detail that used to be uniform across the API.

Takeaways

  1. 1Audit before deleting: df.duplicated(subset=key).sum() for the count, df[df.duplicated(subset=key, keep=False)] for the rows, then df.drop_duplicates(subset=key).
  2. 2Choose the key, do not accept the default — an all-columns comparison keeps a row alive because one timestamp differs.
  3. 3keep='first' suits logs, keep='last' suits append-only exports, and keep=False means 'delete every copy': an audit flag, not a cleanup flag.
  4. 4Add ignore_index=True whenever the deduped frame feeds arithmetic, merges or plotting — surviving original labels leave gaps that make later positional access lie.
  5. 5On object columns, convert lists to tuples before deduping: multi-column list cells raise TypeError, and single-column ones drop into a per-cell hash path I measured ~650x slower than the tuple version on 4,000 rows.

Related commands

← all learning