Index Ai Academy
Phase 3 · Machine learning · Data analysis

Cleaning messy data

Duplicates, junk rows, text numbers, mixed dates and missing values that mean something.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Real data is never as tidy as a textbook's. Exports run twice, staff enter test rows, and three people type the same category three ways. A model trained on that mess learns the mess, and a report built on it quietly gives wrong answers. Cleaning is most of a data job, and it's where the judgment is: every fix is a decision about what the data really means.

What you'll be able to do

  • Find and remove duplicate rows, test entries and impossible values, and say why each goes
  • Turn text like "$1,194.00", "USD 35" and "Free" into numbers, and messy labels into a fixed set
  • Parse dates written in two formats without mixing up days and months
  • Decide what a missing value means before filling it in or leaving it

What it covers

  1. The brief
  2. Duplicates
  3. How many copies?
  4. Rows that shouldn't be there at all
  5. Text to numbers
  6. Categories: one spelling each
  7. Dates in two formats
  8. Missing values: what does empty mean?
  9. Worked example: the whole clean, step by step

The brief

The practice shop is thinking about selling on a third-party gear marketplace. Someone exported the marketplace's current listings on September 1, 2025, and wants the typical price per category. Before you can answer, look at what you've been given:

import pandas as pd

raw = pd.read_csv("https://api.gradient.test/v1/datasets/marketplace.csv")
print(raw.shape)
print(raw.dtypes)
print(raw[["listing_id", "category", "price", "units_sold", "listed"]].head(8))
It runs right here in your browser, with nothing to install.

Nearly every column is text (str), even the prices. The first eight rows already show five problems: prices with and without dollar signs, one category spelled several ways, sales figures like "1,000+", empty cells, and dates in two formats. You can't take a median of that.

A cleaning job goes in a fixed order. Remove rows that shouldn't be there first, so you don't waste effort fixing them. Then fix the types and labels of what's left. Then decide about missing values. Keep a count at each step, so you can say exactly what you removed and why.

Duplicates

duplicated() marks each row that repeats an earlier one. Two rows can be duplicates in different ways, so be clear which you mean:

import pandas as pd

raw = pd.read_csv("https://api.gradient.test/v1/datasets/marketplace.csv")

print(raw.duplicated().sum(), "rows repeat another row exactly")
print(raw["listing_id"].duplicated().sum(), "rows repeat a listing_id")

repeats = raw[raw["listing_id"].duplicated(keep=False)].sort_values("listing_id")
print(repeats[["listing_id", "title", "reviews"]].head(6))

No row is an exact copy, but 12 listing IDs appear twice. keep=False marks every copy, not just the second, so you can look at the pairs side by side. Each pair differs only in its review count: the scraper visited those listings twice, a few days apart. The later visit has more reviews, so it's the one to keep:

df = raw.sort_values("reviews").drop_duplicates(subset="listing_id", keep="last")

Sorting by reviews puts the later copy last, and keep="last" keeps it. Always look at the duplicates before you drop them; the right one to keep depends on why they exist.

Drill

How many copies?

Set repeated_ids to the number of different listing IDs that appear more than once in raw, and extra_rows to how many rows drop_duplicates(subset="listing_id") would remove.

import pandas as pd

raw = pd.read_csv("https://api.gradient.test/v1/datasets/marketplace.csv")
repeated_ids = None
extra_rows = None
In the lesson, your code is checked as you write it.

Rows that shouldn't be there at all

Some rows aren't data. Two listings are tagged test: "TEST listing please ignore" and a "Sample tent" at $9,999. Two more were listed in January and February 2026, months after the export was taken. A listing can't be listed after the snapshot that contains it, so those dates are wrong. They might be typos, or a clock error at the marketplace. Either way, you can't trust those rows.

df = df[~df["tags"].str.contains("test")]          # ~ means "not"
df = df[df["listed"] <= pd.Timestamp("2025-09-01")]  # after the dates are parsed

These are domain rules: facts about the business that the data must obey. Write them down as checks, and you'll catch the same problem in next month's export too. Good rules for this table: a price is never negative, a rating is between 1 and 5, a date is never after the export.

Try it now

Each fix is a decision about what the data means. When in doubt, keep the row and flag the problem rather than inventing a value.

Text to numbers

The price column mixes "$89.00", "25", "USD 35", "$1,194.00" and "Free". The string methods under .str work on every value at once, and a regular expression (a pattern for text) removes all the junk in one call:

import pandas as pd

price = pd.Series(["$89.00", "25", "USD 35", "$1,194.00", "Free", "12 dollars"])
digits = price.str.replace(r"[$,]|USD", "", regex=True).str.strip()
print(digits.tolist())

print(pd.to_numeric(digits, errors="coerce").tolist())
print(pd.to_numeric(digits.replace("Free", "0"), errors="coerce").tolist())

The pattern [$,]|USD means "a dollar sign or a comma, or the letters USD". Replacing it with nothing leaves just the digits. errors="coerce" turns anything that still isn't a number into NaN instead of crashing. That makes it a good detector: convert, then look at the rows that came out NaN to find the cases your pattern missed. Here it caught "Free" and the made-up "12 dollars".

The units column has its own twist. "1,000+" means at least a thousand. Converting it to 1000 is fair, but it's a floor, not a count. Keep a flag column, units_capped, so anyone ranking sellers later knows those numbers are cut off.

Quick check
What does this print?
import pandas as pd
s = pd.Series(["$5", "7", "n/a"])
print(pd.to_numeric(s.str.replace("$", ""), errors="coerce").isna().sum())
  1. 1
  2. 2
  3. It raises an error
  4. 0
Show the answer

1

"$5" becomes 5 and "7" becomes 7. "n/a" can't be a number, so coerce makes it NaN: one missing value.

Categories: one spelling each

Count the raw categories and you'll find 21 spellings for five categories: "TENTS", " tents", "tent", "Backpacks", "shoes", "camp-kitchen" and so on. Fix them in two moves. First, .str.strip().str.lower() removes spaces at the ends and makes everything lowercase. That alone merges most variants. Then a dictionary maps the remaining synonyms to one name:

CATEGORY = {
    "tents": "tents", "tent": "tents",
    "packs": "packs", "backpacks": "packs",
    "footwear": "footwear", "shoes": "footwear",
    "apparel": "apparel", "clothing": "apparel",
    "camp kitchen": "camp kitchen", "kitchen": "camp kitchen", "camp-kitchen": "camp kitchen",
}
df["category"] = df["category"].str.strip().str.lower().map(CATEGORY)
print(df["category"].isna().sum())     # 0, or a spelling the dictionary missed

map gives NaN for any value that isn't a key in the dictionary. That's a feature: the isna().sum() check right after tells you if next month's export brings a spelling you haven't seen, instead of letting it slip through.

Dates in two formats

Most dates look like "2024-01-12", but about a third look like "06/09/2024". Is that June 9 or September 6? In the United States it's June 9; in most other countries it would be September 6. If you let pandas guess, it may guess differently on different rows. Parse each format explicitly instead:

import pandas as pd

listed = pd.Series(["2024-01-12", "06/09/2024", "2025-04-27", "08/26/2024"])
iso = pd.to_datetime(listed, format="%Y-%m-%d", errors="coerce")
us = pd.to_datetime(listed, format="%m/%d/%Y", errors="coerce")
dates = iso.fillna(us)
print(dates.dt.strftime("%B %d, %Y").tolist())
print(dates.isna().sum(), "dates failed both formats")

Each attempt fills in the rows it understands and leaves NaT ("not a time") for the rest. fillna combines them. A date that fails both formats stays NaT, and the count on the last line tells you about it. With real datetime values you can compare dates, take the month with .dt.month, and drop the listings dated after the export.

Missing values: what does empty mean?

There are two kinds of empty cell in this table, and they need opposite treatment:

  • Units sold is empty when a listing has never sold anything. Empty means zero here, so filling it with 0 isn't a guess. It's the true value.
  • Rating is empty when a listing has no reviews. No rating exists, so filling in 0 would claim every unreviewed listing was rated terrible. Leave it missing; mean() and median() skip it.

Ask "why is this missing?" before you touch a missing value. Missing because nothing happened yet (no sales, no reviews) is different from missing because someone forgot to type it in.

One more messy shape: the tags column holds several values in one cell, like "hiking;budget". .str.split(";") turns each cell into a list, and .explode() gives each tag its own row, so value_counts() can count them:

tag_counts = df["tags"].str.split(";").explode().value_counts()
Worked example

Worked example: the whole clean, step by step

Put the steps together, counting rows as you go, then answer the brief.

import pandas as pd

raw = pd.read_csv("https://api.gradient.test/v1/datasets/marketplace.csv")
CATEGORY = {"tents": "tents", "tent": "tents", "packs": "packs", "backpacks": "packs",
            "footwear": "footwear", "shoes": "footwear", "apparel": "apparel", "clothing": "apparel",
            "camp kitchen": "camp kitchen", "kitchen": "camp kitchen", "camp-kitchen": "camp kitchen"}

df = raw.sort_values("reviews").drop_duplicates(subset="listing_id", keep="last")
print(len(raw), "->", len(df), "after removing repeat scrapes")
df = df[~df["tags"].str.contains("test")].copy()
print(len(df), "after removing test listings")

df["price"] = pd.to_numeric(df["price"].str.replace(r"[$,]|USD", "", regex=True).str.strip().replace("Free", "0"))
df["units_capped"] = df["units_sold"] == "1,000+"
df["units_sold"] = pd.to_numeric(df["units_sold"].str.replace(r"[,+]", "", regex=True)).fillna(0).astype(int)
df["category"] = df["category"].str.strip().str.lower().map(CATEGORY)
df["listed"] = pd.to_datetime(df["listed"], format="%Y-%m-%d", errors="coerce").fillna(
    pd.to_datetime(df["listed"], format="%m/%d/%Y", errors="coerce"))
df = df[df["listed"] <= pd.Timestamp("2025-09-01")]
print(len(df), "after removing listings dated after the export")

print(df["category"].isna().sum(), "unmapped categories")
print(df.groupby("category")["price"].median())
In the lesson you can change this and run it.

The row counts tell the story: 316 rows in, 12 repeat scrapes out, 2 test listings out, 2 impossible dates out, 300 real listings left. .copy() after the filter makes df a table of its own, so adding columns to it can't touch raw. Each column fix is one line, and the isna().sum() check confirms every category was recognized. Only then is the median per category worth reporting. A median is a good choice here: a few very expensive listings would pull a mean upward, but the median is the price of the typical listing.

Practice what you just read

  • 9 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why parse "06/09/2024" with an explicit format instead of letting pandas guess?

  1. It could mean June 9 or September 6, and a guess might differ from row to row
  2. pandas can't read dates with slashes
  3. Explicit formats are faster but give the same answer
  4. Guessing turns the year into 1924

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson