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.
$ 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-11The 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 120Two 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.0Why 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-11The 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
- 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).
- 2Choose the key, do not accept the default — an all-columns comparison keeps a row alive because one timestamp differs.
- 3keep='first' suits logs, keep='last' suits append-only exports, and keep=False means 'delete every copy': an audit flag, not a cleanup flag.
- 4Add ignore_index=True whenever the deduped frame feeds arithmetic, merges or plotting — surviving original labels leave gaps that make later positional access lie.
- 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.