pandas · difficulty ◆◆
pandas duplicated() — flag and count the rows that repeat
drop_duplicates fixes the data; duplicated() is the audit that tells you how bad it was — verified on the 3.0.3 install behind this page.
A join added 500 to your revenue report and not one row looks wrong — the money is sitting in a duplicate row that duplicated() will point at.
$ df.duplicated()What it does
df.duplicated() returns a boolean Series, same length and same index as the frame, with True on every row that repeats a row above it. With keep='first' (the default) the first occurrence stays False and every later copy is flagged; keep='last' inverts that; keep=False marks every member of a repeated group, so the 'original' is flagged too. subset= narrows the comparison to the columns that define identity — subset="email" for a customer export, subset=["order_id"] for a join key — and the index is never part of the comparison. Comparison is by value, and nulls compare equal to each other. Nothing is removed, nothing is sorted, no copy of your data is made: you get a mask, and what you do with the mask is up to you.
Why it matters
Duplication is the failure mode that hides best, because every row it creates is individually valid. A CRM export loaded twice doubles your pipeline. A many-to-many join multiplies revenue once per extra match. A customer that exists twice under two ids quietly splits a lifetime value in half. In all three cases drop_duplicates() 'fixes' the file and tells you nothing — you never learn the row-count discrepancy, so you cannot tell whether a process needs repairing. duplicated() is the same machinery in audit mode: it gives you the count, the surplus rows and the duplicated keys, which is what a ticket needs. Put it before every drop_duplicates() in a pipeline, and before every total you show anyone.
Example
$ import pandas as pd
invoices = pd.DataFrame({
"invoice_no": ["INV-9001", "INV-9002", "INV-9002", "INV-9003", "INV-9004", "INV-9004"],
"customer": ["Northwind", "Acme", "Acme", "Globex", "Initech", "Initech"],
"amount": [1200.00, 480.00, 480.00, 950.00, 310.00, 310.00],
})
print(invoices.duplicated())
print("extra copies:", invoices.duplicated().sum())
print(invoices[invoices.duplicated()].to_string(index=False))0 False
1 False
2 True
3 False
4 False
5 True
dtype: bool
extra copies: 2
invoice_no customer amount
INV-9002 Acme 480.0
INV-9004 Initech 310.0The audit I run first on any nightly export that suddenly grew: duplicated() with no arguments compares all three columns, and the two rows it flags are the ones a double-loaded batch created. Mind what the count means — .sum() returns 2, the number of superfluous rows, not the number of invoices inside duplicate groups. That 2 is what you subtract from len(invoices) to prove the file is correct again, and it is also what drop_duplicates() would silently have removed. The index labels on the left are your original ones, which is the point: this is a mask over your frame, so df[df.duplicated()] is a printable change record for the ticket and df[~df.duplicated()] dedupes without throwing anything else away.
$ import pandas as pd
customers = pd.DataFrame({
"customer_id": ["C-01", "C-02", "C-03", "C-04", "C-05"],
"email": ["ana@shop.at", "bo@shop.de", "ana@shop.at", "cy@shop.ch", "bo@shop.de"],
"country": ["AT", "DE", "AT", "CH", "DE"],
"updated": pd.to_datetime(["2026-01-05", "2026-02-11", "2026-03-02", "2026-03-19", "2026-04-01"]),
})
mask = customers.duplicated(subset=["email"], keep=False)
print(customers[mask].sort_values("email").to_string(index=False))
print("rows flagged:", mask.sum(), "| distinct emails involved:", customers.loc[mask, "email"].nunique())customer_id email country updated
C-01 ana@shop.at AT 2026-01-05
C-03 ana@shop.at AT 2026-03-02
C-02 bo@shop.de DE 2026-02-11
C-05 bo@shop.de DE 2026-04-01
rows flagged: 4 | distinct emails involved: 2subset= is what turns row-matching into an identity check: these rows are not identical — different ids, country and updated dates — but two customers share one email, and keep=False brings back both parties so a human can decide which record wins. That keep=False is deliberate. With the default keep='first' the output would have contained only C-03 and C-05, and a list of orphans is not enough evidence to merge anything. The report form I use: duplicated(subset=["email"], keep=False).sum() / 2 is the number of merge decisions waiting for a person, and the same call on the id column tells you whether the CRM's own key is unique — the one assumption every downstream merge is built on.
$ import pandas as pd
orders = pd.DataFrame({"order_id": ["O-1", "O-2", "O-3"], "revenue": [500.0, 250.0, 700.0]})
shipments = pd.DataFrame({"order_id": ["O-1", "O-1", "O-2", "O-3"], "box": ["B1", "B2", "B3", "B4"]})
joined = orders.merge(shipments, on="order_id")
print("orders:", len(orders), "| joined rows:", len(joined), "| revenue sum:", joined["revenue"].sum())
print(joined[joined.duplicated(subset=["order_id"], keep=False)].to_string(index=False))
print("extra copies the join created:", joined.duplicated(subset=["order_id"]).sum())orders: 3 | joined rows: 4 | revenue sum: 1950.0
order_id revenue box
O-1 500.0 B1
O-1 500.0 B2
extra copies the join created: 1This is the check I now run on every join whose right-hand table is not proven unique on the key: straight after the merge, duplicated(subset=["order_id"]) reports how many rows the join invented. One extra box for O-1 turned three orders into four rows and produced 1,950.00 of revenue that is really 1,450.00 — a 34% error from a merge that succeeded completely, with no NaN, no missing row and no warning. Run it before the groupby, because once you aggregate, duplication is indistinguishable from real volume. Same trick reconciles a concat: concat the batches, then duplicated(subset=key).sum().
$ import pandas as pd
sync = pd.DataFrame({
"meter": ["M-41", "M-42", "M-41", "M-43", "M-42"],
"kwh": [311.4, 180.9, 318.2, 92.7, 181.5],
"synced": pd.to_datetime(["2026-10-10", "2026-10-10", "2026-10-12", "2026-10-11", "2026-10-12"]),
})
superseded = sync.duplicated(subset=["meter"], keep="last")
print(sync[superseded].to_string(index=False))
print("superseded:", superseded.sum(), "| kept:", (~superseded).sum())
print(sync[~superseded].sort_values("meter").to_string(index=False))meter kwh synced
M-41 311.4 2026-10-10
M-42 180.9 2026-10-10
superseded: 2 | kept: 3
meter kwh synced
M-41 318.2 2026-10-12
M-42 181.5 2026-10-12
M-43 92.7 2026-10-11Meter readings get re-uploaded with corrected values, so the newest row per meter is the one that counts — and keep='last' is how you find the rows to retire instead of blindly calling drop_duplicates(). The trap is in the name: 'last' means last in ROW order, not last by date. This frame happens to be in sync order; if it were not, you would sort_values("synced") first, and until you did, the mask would be meaningless. So: sort first, then flag. Use df.sort_values("synced").drop_duplicates(subset="meter", keep="last") when you only need the survivors, and the mask above when a reviewer wants to see which 2 of 5 readings were replaced and by how much — 311.4 superseded by 318.2 is a 6.8 kWh correction no other report would surface.
Common flags
- subset="email" / subset=["order_id", "box"]
- the columns that define identity. A plain string works for one column; a tuple counts as a single label if it matches a MultiIndex column and as a list of labels otherwise (frame.py:8071); a set works too, but a generator does not — subset=iter(["a","b"]) raises TypeError: object of type 'list_iterator' has no len() on 3.0.3, verified.
- keep='first' (default)
- flags every copy after the first occurrence, so the surviving original is the row your frame already had. This is the mode that answers 'how many rows would drop_duplicates() remove?'
- keep='last'
- flags every copy except the last one in row order — row order, not date order. Sort by your timestamp column first, or which row you keep is an accident of load order.
- keep=False
- flags every member of a repeated group, original included, and it is the only mode that shows the whole cluster. It is also the value that once shipped broken: 0.18.0 fixed spurious matches with keep=False (GH 11864).
- missing values are equal
- NaN, NaT, None and pd.NA all compare as duplicates of each other, in float, Int64 and str dtypes alike (verified: pd.Series([1.0, nan, nan, 2.0, 2.0]).duplicated() → [False, False, True, False, True]). Your 'no email' rows are one group, not forty distinct customers.
- the index is ignored
- df.duplicated() looks only at values: index ['A','B','A'] over distinct rows returns all False, while df.index.duplicated() returns [False, False, True] (verified on 3.0.3). For duplicate column labels it is df.columns.duplicated().
- returns a mask, not a frame
- invert it with ~ for the deduped view (df[~df.duplicated()]) and use .sum() and len() for the ratio. On an empty frame it returns an empty bool Series instead of raising (verified).
History
0.6.0, November 25, 2011 — duplicated() and drop_duplicates() arrive together
The v0.6.0 whatsnew announces both methods in a single line: 'Added drop_duplicates and duplicated functions for removing duplicate DataFrame rows and checking for duplicate rows, respectively (GH 319)'. The 2011 signature, read straight out of pandas/core/frame.py at that tag, is duplicated(self, col_or_columns=None, take_last=False) — no subset, no keep, and take_last as a plain boolean meaning 'keep the last one instead of the first'. The two methods have been published as a pair ever since, which is probably why dropping without auditing feels normal. It never was: they were always two tools with two jobs.
Three renames in nine years, one bug, and an API retired into a one-liner
The first argument was col_or_columns in 0.6.0, became cols by 0.8.0, and settled as subset in 0.15.0 — three names across three tags, each verified in frame.py at that tag. The second argument moved slower: keep arrived in 0.17.0 (October 9, 2015) accepting 'first', 'last' and 'all', take_last was deprecated in the same release (GH 6511, GH 8505), and the old keyword was dropped in 0.20.0 (GH 10236, GH 10792). In between came the bug worth remembering: 0.18.0 (March 13, 2016) had to fix 'Bug in DataFrame.duplicated and drop_duplicates causing spurious matches when setting keep=False' (GH 11864) — for one release, the mode that flags every member of a group flagged strangers. Then in 1.0.0 (January 29, 2020) pandas deleted Index.get_duplicates and replaced it with the expression it always should have been: idx[idx.duplicated()].unique() (GH 20239). Method removed, idea kept, in one line.
Fun facts
Pros & cons
pros
- + Returns a mask over your own index, so the audit and the fix are the same expression: df[df.duplicated()] to show the surplus rows, df[~df.duplicated()] to dedupe, df["was_dupe"] = df.duplicated() to leave a trail
- + subset= turns it into a business-key validator: duplicated(subset=["email"], keep=False) finds collisions that a full-row drop_duplicates() would never see, and returns every party so a human can decide which record wins
- + Hash-table implementation, so it scales the way the rest of pandas does: factorize the key columns once, one lookup per row, no sorting of your data and no copy of the frame
cons
- − It sees values only — never index labels, never column order, never what the frame means. A duplicated index and a duplicated report are two different problems and this method reports neither of them by default
- − keep='last' is positional: on an unsorted frame the 'newest' row you keep is an accident of load order. There is no keep='newest' and no on= parameter to tell pandas which column carries the timestamp
- − Missing values collapse silently into one group, so forty invoices with no customer id get reported as 39 surplus rows. That is often the right answer and almost never what the person asking expected
Takeaways
- 1Run duplicated() before every drop_duplicates(): .sum() is the count you put in the change note and df[df.duplicated()] is the evidence a reviewer will ask for. Dropping without that number is how a data-quality regression becomes a permanent feature.
- 2Pass a business key, not the whole row. subset=["order_id"] or subset=["email"] with keep=False finds collisions a full-row comparison is blind to, because the rows differ in every other column.
- 3After any many-to-many merge, check duplicated(subset=[key]).sum() before you aggregate — one extra matching row silently multiplies revenue, and once you sum by group the duplication is invisible.
- 4Never trust keep='last' on an unsorted frame: 'last' means last in row order, so sort by your timestamp column first. Sort, then flag — the other order is how you keep the oldest record and call it the newest.
- 5Missing values count as one duplicate group and the index is never compared. For the index or the column labels, ask df.index.duplicated() or df.columns.duplicated() — the frame-level method will not tell you.