Cleaning data

Cleaning is where analysts spend most of their time. The aim is a table where every column has the right type, every row means one thing, and the missing values are handled deliberately rather than by accident.

A typical cleaning pass

df = df.drop_duplicates()
df["city"] = df["city"].str.strip().str.title()
df["sales"] = pd.to_numeric(df["sales"], errors="coerce")
df["sales"] = df["sales"].fillna(df["sales"].median())
df["date"] = pd.to_datetime(df["date"], dayfirst=True)
df = df.rename(columns=str.lower)

Tip: our Clean & Deduplicate Data tool does many of these steps without code.

Step 1: diagnose first

print(df.isna().sum())
print(df.isna().mean().mul(100).round(1))      # % missing per column
print(df.duplicated().sum())
print(df.duplicated(subset=["bill_id"]).sum())
print(df.dtypes)

Never clean blindly. A column that is 60% missing probably should be dropped, not filled.

Step 2: duplicates

drop_duplicates() compares every column, which misses the common case of the same bill re-imported with a different timestamp. Define what makes a row unique:

df = df.sort_values("updated_at").drop_duplicates(subset=["bill_id", "product"], keep="last")

Step 3: missing values

Strategy When to use Code
Drop rows Few rows, key field missing df.dropna(subset=["bill_id"])
Drop column Mostly empty, not needed df.drop(columns=["remarks"])
Constant Missing means "none" df["discount"].fillna(0)
Median / mode Numeric or categorical gaps df["age"].fillna(df["age"].median())
Forward fill Time series, value carries df["rate"].ffill()
Group-aware fill Fill within each city see below
df["sales"] = df["sales"].fillna(df.groupby("city")["sales"].transform("median"))

Filling a city's missing sales with that city's median is far better than one global median. Record how many values you filled — it belongs in your notes.

Step 4: types

df["amount"] = (df["amount"].astype(str)
                            .str.replace(",", "", regex=False)
                            .str.replace("₹", "", regex=False)
                            .pipe(pd.to_numeric, errors="coerce"))
df["gstin"] = df["gstin"].astype("string").str.upper().str.strip()
df["date"] = pd.to_datetime(df["date"], dayfirst=True, errors="coerce")
print(df["date"].isna().sum(), "dates failed to parse")

Indian exports often write amounts as "1,20,000" and dates as DD/MM/YYYY, so dayfirst=True and comma stripping are routine. errors="coerce" turns bad values into NaN instead of raising, so you can count and inspect them.

Step 5: messy text and categories

df["city"] = (df["city"].str.strip().str.title()
                        .replace({"Bangalore": "Bengaluru", "Bombay": "Mumbai"}))
print(df["city"].value_counts())

Always print value_counts() afterwards — that is how you spot "Delhi" and "New Delhi" still living apart.

Step 6: impossible values

df = df[df["qty"] > 0]
df.loc[df["age"] > 110, "age"] = pd.NA
df["discount_pct"] = df["discount_pct"].clip(0, 100)

Common mistakes

  • Filling missing values before understanding why they are missing.
  • Using the mean on skewed money columns; the median is safer.
  • Cleaning in a notebook by hand so the work cannot be repeated next month — put it in a function.
  • Silently dropping rows; always print the row count before and after.

Practice

Take a messy CSV (mixed city spellings, comma amounts, DD/MM dates, some blanks). Write a clean(df) function that does every step above and prints rows dropped and values filled. Run it twice on the same file and confirm the second run changes nothing.

B

Written by Bablu Kumar Jha

Founder, BeeProton AI · Mumbai, Maharashtra, India