Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Analysis with Python (pandas, polars, DuckDB)  Module 4 – Cleaning: missing values, duplicates, types and text

Duplicates, keys and the latest record

Find exact and key-based duplicates in pandas, keep the latest record per key with a stated tie rule, test keys before joins, and spot real repeats.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, pandas 3.0.6, duckdb 1.5.6 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Find exact and key-based duplicates with duplicated and drop_duplicates
  • Select the latest record per key with sorting plus drop_duplicates, or with idxmax
  • Test that a set of columns is a unique key before joining or reporting
  • Distinguish true duplicates from legitimate repeats

Before you start

What you’ll learn · Before you start

What you will learn

  • Find exact and key-based duplicates with duplicated and drop_duplicates
  • Select the latest record per key with sorting plus drop_duplicates, or with idxmax
  • Test that a set of columns is a unique key before joining or reporting
  • Distinguish true duplicates from legitimate repeats

Before you start

On this page

Two rows are duplicates when they record the same real thing more than once: a payment loaded twice, one customer under two spellings, the same update sent by two systems. Duplicates inflate counts and totals, and a join on a repeated key multiplies rows without any warning. pandas finds rows that are copies of an earlier row with duplicated(), rows that repeat a key, the columns that should identify one row, with duplicated(subset=key), and keeps one row per group with drop_duplicates().

Deleting is the easy part. The decisions that change your answer come first: which columns form the key, which row of a group to keep when the rows differ, and whether a repeat is an error at all, because two customers can buy the same thing on the same day. This lesson takes those decisions one at a time and ends with the checks that keep a key honest before every join.

Exact duplicates and duplicates by key

Copies of rows and repeats of a key Python · find_duplicates.py
import pandas as pd

payments = pd.DataFrame({
    "payment_id": ["P1", "P2", "P2", "P3", "P4", "P4", "P4"],
    "student": ["Asha", "Ben", "Ben", "Chen", "Dev", "Dev", "Dev"],
    "amount": [1200, 950, 950, 600, 700, 700, 750],
})

# Exact duplicates: every column equal to an earlier row.
print("Exact copies:", payments.duplicated().tolist())

# Duplicates by key: payment_id should identify one payment.
key = ["payment_id"]
print("Repeated keys:", payments.duplicated(subset=key).tolist())

# keep=False marks every member of a duplicate group, so you can review whole groups.
print(payments[payments.duplicated(subset=key, keep=False)].to_string())

# How big are the groups?
sizes = payments.groupby(key).size().value_counts().sort_index()
print(sizes.rename_axis("rows per key").rename("keys").to_string())
print("Rows after drop_duplicates():", len(payments.drop_duplicates()), "of", len(payments))

Output

Exact copies: [False, False, True, False, False, True, False]
Repeated keys: [False, False, True, False, False, True, True]
  payment_id student  amount
1         P2     Ben     950
2         P2     Ben     950
4         P4     Dev     700
5         P4     Dev     700
6         P4     Dev     750
rows per key
1    2
2    1
3    1
Rows after drop_duplicates(): 5 of 7

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 find_duplicates.py

Row 2 is a full copy of row 1, and row 5 of row 4, so duplicated() marks those two. Row 6 has the same payment_id as rows 4 and 5 but a different amount: only the key test catches it. With the default keep="first", the first row of each group counts as the original and later rows are marked; keep="last" marks all but the last, and keep=False marks every member of a group, which is what you want when you review groups before deciding.

drop_duplicates() with no subset removes the two exact copies and keeps P4 twice, with 700 and with 750. That is the right first step only if exact copies are known to be mistakes. Whether P4 was paid once or twice, and how much, is a question for whoever owns the payments, and the table of group sizes tells you how many such questions you have.

Remove Duplicate Rows (CSV/Excel) Remove exact or key-based duplicate rows from a CSV, ignoring case or spaces, and get a report of what went.

Keep the latest record per key

Logs of changes, such as address updates, hold many rows per customer, and the analysis usually wants the current one. pandas gives two common ways to keep the latest row per key:

Two ways to keep each customer's latest address

latest_address.py

import pandas as pd

log = pd.read_csv("address_log.csv", parse_dates=["updated_at"])
log["row"] = range(len(log))  # the order rows arrived in: the tie-breaker

# Way 1: sort, then keep the last row of each customer.
by_sort = log.sort_values(["customer_id", "updated_at"]).drop_duplicates("customer_id", keep="last")

# Way 2: the position of each customer's latest timestamp.
by_max = log.loc[log.groupby("customer_id")["updated_at"].idxmax()]

print(by_sort[["customer_id", "updated_at", "city", "row"]].to_string(index=False))
print(by_max[["customer_id", "updated_at", "city", "row"]].to_string(index=False))

# C101 has two updates at the same minute: each way picks a different one. Make the rule explicit.
latest = log.sort_values(["customer_id", "updated_at", "row"]).drop_duplicates("customer_id", keep="last")
dropped = log.drop(latest.index)
print("Kept:", latest["city"].tolist())
print("Dropped rows:", sorted(dropped["row"].tolist()))

Output

customer_id          updated_at    city  row
       C101 2026-04-18 16:05:00  Nashik    5
       C102 2026-05-01 12:00:00 Udaipur    4
       C103 2026-03-09 11:20:00   Kochi    6
customer_id          updated_at    city  row
       C101 2026-04-18 16:05:00  Nagpur    2
       C102 2026-05-01 12:00:00 Udaipur    4
       C103 2026-03-09 11:20:00   Kochi    3
Kept: ['Nashik', 'Udaipur', 'Kochi']
Dropped rows: [0, 1, 2, 3]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 latest_address.py

address_log.csv

customer_id,updated_at,city,source
C101,2026-03-02 09:15,Pune,app
C102,2026-03-02 10:40,Jaipur,branch
C101,2026-04-18 16:05,Nagpur,app
C103,2026-03-09 11:20,Kochi,app
C102,2026-05-01 12:00,Udaipur,branch
C101,2026-04-18 16:05,Nashik,call centre
C103,2026-03-09 11:20,Kochi,app

Sorting by key and time and then keeping the last row of each key, and asking groupby(...)["updated_at"].idxmax() for the position of each key’s latest time, agree for C102, and for C103 they pick different copies of the same row. For C101 they disagree: two updates arrived in the same minute, Nagpur from the app and Nashik from the call centre, and idxmax() returns the first row that holds the maximum while the sort keeps the later one. Neither is wrong; each applies a tie rule nobody wrote down, and the customer’s city depends on which method a colleague happens to use.

The cure is to make the tie rule part of the sort. Here a row column records the order the rows arrived in, and sorting by ["customer_id", "updated_at", "row"] makes “the most recently received update wins” explicit; the same input now gives the same answer every time. Other rules are just as valid, such as preferring one source over another, as long as the rule is written down. Print what you drop: four rows went, including C103’s exact copy.

Normalise the key before you compare

People and systems type the same value in different ways, so equal keys can look different:

Duplicates that appear only after normalising Python · normalised_keys.py
import pandas as pd

contacts = pd.DataFrame({
    "name": [" Ravi Kumar", "ravi  kumar", "Meera Iyer", "MEERA IYER ", "Meera Iyer"],
    "email": ["Ravi.K@Example.com", "ravi.k@example.com ", "meera@example.org", "meera@example.org", "m.iyer@example.org"],
})
print("Exact duplicates:", int(contacts.duplicated().sum()))

# Build a comparison key in a new column; keep the original text untouched.
contacts["email_key"] = contacts["email"].str.strip().str.casefold()
contacts["name_key"] = contacts["name"].str.strip().str.casefold().str.replace(r"\s+", " ", regex=True)

print("Same email:", int(contacts.duplicated(subset=["email_key"]).sum()))
print("Same name:", int(contacts.duplicated(subset=["name_key"]).sum()))
print(contacts.to_string())

Output

Exact duplicates: 0
Same email: 2
Same name: 3
          name                email           email_key    name_key
0   Ravi Kumar   Ravi.K@Example.com  ravi.k@example.com  ravi kumar
1  ravi  kumar  ravi.k@example.com   ravi.k@example.com  ravi kumar
2   Meera Iyer    meera@example.org   meera@example.org  meera iyer
3  MEERA IYER     meera@example.org   meera@example.org  meera iyer
4   Meera Iyer   m.iyer@example.org  m.iyer@example.org  meera iyer

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 normalised_keys.py

No two rows are exact duplicates, yet after stripping spaces and case-folding, two emails repeat. Build the comparison key in a new column and leave the original text alone, so that the data shows what was received and the key shows how you compared it. str.casefold() exists for caseless matching and removes more case differences than lower(); collapsing runs of spaces with a regular expression handles “ravi kumar”.

The name key finds three repeats where the email key finds two, and the difference is the warning: row 4 is a Meera Iyer with a different address, who may well be a different person. Names make poor keys on their own because people share them. Match on identifiers people do not share, such as an email or an account number, and send the cases that match only on a name to a person for review.

Compare Two Lists Paste two lists of keys to see which appear in both, in one only, or twice.

Test the key before you join

A key is only a key if it is unique, and a join is the place where a repeated key does the most damage:

A repeated key in a lookup table Python · key_checks.py
import pandas as pd

orders = pd.DataFrame({"order_id": [1, 2, 3], "product": ["tea", "rice", "tea"], "qty": [2, 1, 5]})
# The price list got tea's new price appended instead of replacing the old one.
prices = pd.DataFrame({"product": ["tea", "rice", "tea"], "price": [40, 60, 42]})

# Three ways to test a key before trusting it.
print("is_unique:", prices["product"].is_unique)
print("repeated keys:", int(prices.duplicated(subset=["product"]).sum()))
print(prices[prices.duplicated(subset=["product"], keep=False)].to_string(index=False))

# A join on a repeated key multiplies rows: 3 orders become 5 rows.
joined = orders.merge(prices, on="product")
print("rows before:", len(orders), "| after the join:", len(joined))
current = prices.drop_duplicates("product", keep="last")  # one price per product: the newest
right = orders.merge(current, on="product", validate="many_to_one")
print("revenue with one price per product:", int((right["qty"] * right["price"]).sum()))
print("revenue after the bad join:", int((joined["qty"] * joined["price"]).sum()))

# validate= makes pandas check the key and refuse the join.
try:
    orders.merge(prices, on="product", validate="many_to_one")
except pd.errors.MergeError as e:
    print("MergeError:", str(e).splitlines()[0])

Output

is_unique: False
repeated keys: 1
product  price
    tea     40
    tea     42
rows before: 3 | after the join: 5
revenue with one price per product: 354
revenue after the bad join: 634
MergeError: Merge keys are not unique in right dataset; not a many-to-one merge

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 key_checks.py

Three orders joined to a price list that lists tea twice give five rows, and revenue of 634 instead of 354.orders: 3 rows order 1: tea × 2order 2: rice × 1order 3: tea × 5prices: tea twice tea: 40rice: 60tea: 42merge on product: 5 rows order 1: tea × 2 at 40order 1: tea × 2 at 42order 2: rice × 1 at 60order 3: tea × 5 at 40order 3: tea × 5 at 42Revenue: 634, not 354each tea ordermatches twice

A repeated key on the lookup side multiplies rows

Text description of the diagram

Two tables are joined on the product column.

  • orders has 3 rows: order 1 is tea × 2, order 2 is rice × 1, order 3 is tea × 5.
  • prices lists tea twice, at 40 and at 42, and rice once, at 60.

The join pairs each tea order with both tea prices, so it returns 5 rows: order 1 at 40 and at 42, order 2 at 60, order 3 at 40 and at 42. Revenue computed from those rows is 634, while one price per product (the newest, 42 for tea) gives 354.

is_unique, duplicated(subset=...).sum() and the rows marked with keep=False all say the same thing: tea is listed twice, because a new price was appended instead of replacing the old one. Joining 3 orders to that list returns 5 rows, and revenue jumps from 354 to 634 although nobody bought anything more. validate="many_to_one" asks pandas to check that the right-hand key is unique and to raise MergeError instead of joining; put it on every merge where you know the relationship. The other values are "one_to_one", "one_to_many" and "many_to_many" (which checks nothing).

Missing keys need their own check, because pandas and SQL treat them differently:

Missing keys in a pandas join and a SQL join Python · null_keys_join.py
import duckdb
import pandas as pd

orders = pd.DataFrame({"order_id": [1, 2, 3, 4], "customer_id": ["C1", None, "C2", None]})
customers = pd.DataFrame({"customer_id": ["C1", "C2", None], "segment": ["retail", "trade", "unknown"]})

# pandas matches a missing key with a missing key: both orders without a customer get the "unknown" row.
print(orders.merge(customers, on="customer_id", how="left").to_string(index=False))

# SQL never matches NULL to NULL, so DuckDB leaves those two orders without a segment.
print(duckdb.sql("SELECT * FROM orders LEFT JOIN customers USING (customer_id) ORDER BY order_id").df().to_string(index=False))

# A key with gaps is not a key: count them before the join.
print("orders without a customer:", int(orders["customer_id"].isna().sum()))
print("customers without an id:", int(customers["customer_id"].isna().sum()))

Output

 order_id customer_id segment
        1          C1  retail
        2         NaN unknown
        3          C2   trade
        4         NaN unknown
 order_id customer_id segment
        1          C1  retail
        2         NaN     NaN
        3          C2   trade
        4         NaN     NaN
orders without a customer: 2
customers without an id: 1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 null_keys_join.py

pandas matches a missing key with a missing key, as its merge documentation warns, so both orders without a customer pick up the “unknown” customer’s segment. In DuckDB any comparison with NULL, NULL = NULL included, gives NULL rather than true, so the join leaves those orders without a segment. Count the missing keys on both sides before a join, and decide what they should match.

When the key is the index, df.index.is_unique tests it, and df.set_flags(allows_duplicate_labels=False) makes pandas raise as soon as an operation would create a repeated label. The pandas documentation calls that flag experimental, because not every method passes it on yet, so keep an explicit test as well.

True duplicates or real repeats?

Two identical rows are not always an error. In a sales table, two identical lines can be one line scanned twice or a customer who bought a second pack; in sensor data, two equal readings can be a stuck sensor or a stable room. Before removing repeats, check what would make them legitimate:

  • The grain. If a table has one row per payment and a source-issued payment ID, two rows with that ID are errors. If it has one row per item scanned, identical rows can be real.
  • The time. Copies loaded in the same second, or in two loads of the same file, point to a pipeline fault. Copies minutes apart are more likely to be real events.
  • The owner. Whoever runs the source system knows whether it can emit the same record twice.

Real data makes this a judgement call. In the UCI Online Retail II data, 34,335 of the 1,067,371 rows are exact copies of an earlier row (67,242 rows belong to a group of identical rows), with the same invoice, product, quantity, minute and price. The data cannot say whether they are double scans or repeated purchases on one invoice. Record the choice in the data log with the count, report the result both ways if it matters, and ask the data owner. (Measured with pandas 3.0.6 on the full dataset.)

Exercise

Exercise · Medium · Python

Keep the latest complete record per person

A contacts export holds several rows for some people. Write two functions in contacts.py, in plain Python: the same rules you would later express in pandas with a key column, sort_values and drop_duplicates.

Each row is a dictionary with the keys "name", "email", "phone" and "updated" (a date written as "YYYY-MM-DD", so dates compare correctly as text). A field may be an empty string.

email_key(email) returns the comparison key of an email address: without spaces at either end, and case-folded with str.casefold().

latest_contacts(rows) keeps one row per person, where two rows belong to the same person when their email_key values are equal. For each person it keeps:

  1. the row with the latest "updated" date;
  2. if several rows share that date, the one with the most non-empty fields among "name", "email" and "phone";
  3. if they are still tied, the one that comes last in rows.

It returns the kept rows, unchanged, in the order in which each person first appears in rows, and it does not change the input list or its dictionaries.

For example, of {"name": "Ravi", "email": "Ravi@Example.com", "phone": "", "updated": "2026-03-01"} and {"name": "Ravi K", "email": " ravi@example.com", "phone": "97", "updated": "2026-03-01"}, the second is kept: the same person, the same date, and one more field filled in.

The sample tests check the key, each of the three rules, the order of the result and that the input is left as it was.

Starter code · contacts.py

FIELDS = ("name", "email", "phone")  # the fields that count for completeness


def email_key(email):
    """Return email without surrounding spaces, case-folded."""
    # Replace this line with your code.
    return email


def latest_contacts(rows):
    """Return one row per email_key: latest date, then most complete, then last in rows; in first-seen order."""
    # Replace these lines with your code.
    return list(rows)
The sample tests · test_contacts.py
import copy

from contacts import email_key, latest_contacts


def row(name, email, phone, updated):
    return {"name": name, "email": email, "phone": phone, "updated": updated}


def test_email_key():
    """strips spaces and case-folds the address"""
    assert email_key("  Ravi@Example.COM ") == "ravi@example.com"
    assert email_key("meera@example.org") == "meera@example.org"


def test_latest_date_wins():
    """keeps the row with the latest date for each person"""
    rows = [row("Asha", "asha@example.com", "91", "2026-01-05"), row("Asha R", "ASHA@example.com", "", "2026-02-01")]
    assert latest_contacts(rows) == [rows[1]]


def test_more_complete_wins_a_tie():
    """on the same date, keeps the row with more non-empty fields"""
    rows = [row("Ravi K", " ravi@example.com", "97", "2026-03-01"), row("Ravi", "Ravi@Example.com", "", "2026-03-01")]
    assert latest_contacts(rows) == [rows[0]]


def test_last_row_wins_a_full_tie():
    """when date and completeness tie, keeps the row that comes last"""
    rows = [row("Dev", "dev@example.com", "11", "2026-04-02"), row("Devraj", "dev@example.com", "12", "2026-04-02")]
    assert latest_contacts(rows) == [rows[1]]


def test_first_seen_order():
    """returns one row per person, in the order the people first appear"""
    rows = [
        row("Ravi", "Ravi@Example.com", "", "2026-03-01"),
        row("Meera", "meera@example.org", "98", "2026-02-11"),
        row("Ravi K", " ravi@example.com", "97", "2026-03-01"),
        row("Chen", "chen@example.net", "", "2026-01-20"),
        row("Meera Iyer", "Meera@example.org ", "98", "2026-01-30"),
    ]
    assert latest_contacts(rows) == [rows[2], rows[1], rows[3]]


def test_input_unchanged():
    """does not change the list or the dictionaries it is given"""
    rows = [row("Asha", "asha@example.com", "91", "2026-01-05"), row("Asha R", "ASHA@example.com", "", "2026-02-01")]
    before = copy.deepcopy(rows)
    latest_contacts(rows)
    assert rows == before


def test_empty():
    """returns an empty list for no rows"""
    assert latest_contacts([]) == []
A hint

Walk through rows once with enumerate, keeping a dictionary from email_key(...) to the position of the best row so far. A row beats the current best when (updated, filled, position) is larger, where filled counts the non-empty fields among FIELDS: tuples compare item by item. Dictionaries keep insertion order, so the keys come out in first-seen order.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 4 For which rows does df.duplicated(subset=["id"]) return True?

    Choose one answer.

    Show the answer to question 1

    Answer: Every row whose id already appeared in an earlier row

    With the default keep="first", the first row of each id counts as the original and every later row with the same id is marked. keep=False marks every member of the group, the first one included, which is what you want when reviewing groups. Without subset, all columns are compared.

  2. Question 2 of 4 How many rows does this join return?

    Read the code, then choose one answer.

    import pandas as pd
    a = pd.DataFrame({"k": [1, 2, 2]})
    b = pd.DataFrame({"k": [2, 2, 3], "v": [10, 20, 30]})
    print(len(a.merge(b, on="k")))
    Show the answer to question 2

    Answer: 4

    An inner join pairs every matching row on the left with every matching row on the right. Key 1 has no partner and key 3 has none on the left; key 2 appears twice on each side, so it produces 2 × 2 = 4 rows. Repeated keys on both sides multiply rows; validate="one_to_one" would have refused this join.

  3. Question 3 of 4 How many rows does this pandas join return?

    Read the code, then choose one answer.

    import pandas as pd
    a = pd.DataFrame({"k": ["x", None]})
    b = pd.DataFrame({"k": [None], "v": [1]})
    print(len(a.merge(b, on="k")))
    Show the answer to question 3

    Answer: 1

    pandas matches a missing key with a missing key, as its merge documentation warns, so the row with no key on the left pairs with the row with no key on the right. In DuckDB NULL = NULL is NULL, not true, so the same join returns no rows.

  4. Question 4 of 4 Two rows of one customer share the latest timestamp. What is true of sort_values(["customer", "ts"]).drop_duplicates("customer", keep="last") and df.loc[df.groupby("customer")["ts"].idxmax()]?

    Choose one answer.

    Show the answer to question 4

    Answer: They can keep different rows, so add a tie-breaker column to the sort and say which row wins

    idxmax() returns the first row that holds the maximum, while sorting and keeping the last row keeps the later one of the tie, so the lesson's address log ends with Nagpur one way and Nashik the other. A tie-breaker such as the order the rows arrived in makes the result the same every time, and the rule explicit.

Interview questions

What does validate="many_to_one" check in a pandas merge, and when should you use it?

It checks the relationship you expect before pandas joins: with "many_to_one", the key must be unique in the right-hand table, so each left row finds at most one partner. If the right table repeats a key, merge raises MergeError instead of quietly multiplying rows. "one_to_one" requires unique keys on both sides, "one_to_many" on the left, and "many_to_many" checks nothing. Use it on every join whose shape you know, typically facts joined to a lookup table such as orders to products or customers. A lookup table with one product listed twice would otherwise duplicate every order of that product and inflate revenue, with the row count as the only sign. Combine it with a count of missing keys, because pandas matches missing keys with each other.

Key takeaways

  • duplicated() marks copies of earlier rows; duplicated(subset=key) marks repeats of a key; keep=False marks whole groups for review; drop_duplicates() keeps one row per group.
  • Keeping the latest row per key needs a tie rule. Sorting with keep="last" and idxmax() disagree on ties; add a tie-breaker column to the sort and print what you drop.
  • Compare on a normalised key column (strip, casefold(), collapse spaces) and keep the original text.
  • Test keys before joins: is_unique, duplicated(subset=...), and validate= on merge. A repeated key on the lookup side multiplies rows and totals.
  • pandas matches missing keys with each other in a merge; SQL does not. Count missing keys first.
  • Identical rows can be real repeats. Decide with the grain, the timing and the data owner, and log the count.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.