Pandas: Data Cleaning, Missing Values and Duplicates

Pandas data cleaning turns messy real-world data into something you can actually analyze. Learn to detect and handle missing values (NaN, short for Not a Number), remove duplicates, fix data types, handle outliers with IQR (Interquartile Range), and build a systematic cleaning pipeline you can reuse on any dataset.

“Tidy datasets are all alike, but every messy dataset is messy in its own way.”

Hadley Wickham, R for Data Science

Last Updated: July 2026 | Tested on: Python 3.14.6, Pandas 3.0.3 | Difficulty: Advanced | Reading Time: 15 minutes

Real data is messy. Customer ages of -5 or 999. Missing phone numbers stored as empty strings, “N/A”, “null”, or an actual NaN. Duplicate records left behind by a system migration. Dates written three different ways inside the same column. Think of it like a kitchen before you cook: the vegetables are still muddy and the knives are in the wrong drawer. You wash and sort first, then you cook. Skip the cleaning and every analysis you build on top tastes like dirt.

This post gives you a simple, repeatable pandas data cleaning routine that turns messy data into something you can trust. When you want to run that routine against a full dataset end to end, the pandas project walkthrough gives you exactly that practice.

A data scientist named Niranjan once trained a machine learning model on raw customer data and got 99% accuracy. He was thrilled, until he looked closer. The dataset was full of duplicate rows, so the same records showed up in both training and testing. The model was not learning anything, it was just recognizing copies it had already seen. After he removed the duplicates and cleaned the rest, accuracy dropped to a realistic 78%. That 78% was the honest number he could actually ship. The 99% was a lie his data had told him.

Fix types:astype, to_datetimeStage 4: OutliersFoundNoneOutliers?IQR methodCap or Removedf.clip()Clean DatasetStage 2 and 3: Duplicates and Data TypesFoundNoneDuplicates?df.duplicated()Removedf.drop_duplicates()Wrong dtypes?df.dtypesStage 1: Missing Values< 5%5%Raw Datadf.shape, df.info()Missing?df.isnull().sum()Drop rowsdf.dropna()Imputemean / median / modePython Pandas Data Cleaning: From Raw Data to a Clean Dataset in Four Stages

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The diagram shows a data cleaning pipeline in four stages: handle missing values (dropna(), fillna()), remove duplicates (drop_duplicates()), fix data types (astype(), pd.to_datetime()), and detect outliers (IQR or z-score methods). Almost every real dataset needs some mix of these steps before analysis can begin. Doing them in this order, missing values first, then duplicates, then types, then outliers, keeps one problem from hiding another. For example, if you fix types before you drop duplicate rows, you waste effort converting rows you are about to delete anyway.

Prerequisites

Complete Pandas loc and iloc tutorial. You should be comfortable with loc, iloc, and boolean indexing.

Recipe 1: Detecting Missing Values

📄 missing_detection.py: find what is missing

import pandas as pd
import numpy as np

# Messy dataset
df = pd.DataFrame({
    "name": ["Rahul", "Niranjan", "Viraj", None, "Sardar", "Prathamesh"],
    "age": [28, 31, np.nan, 29, 27, 33],
    "city": ["Mumbai", "Pune", "Mumbai", "Delhi", np.nan, "Mumbai"],
    "salary": [75000, np.nan, 71000, 65000, 78000, 91000],
    "email": ["rahul@mail.com", "", "viraj@mail.com", "N/A", "sardar@mail.com", None]
})

print(f"Raw data:\n{df}\n")

# Count missing values per column
print(f"Missing per column:\n{df.isnull().sum()}\n")

# Percentage missing
pct_missing = (df.isnull().sum() / len(df) * 100).round(1)
print(f"Percentage missing:\n{pct_missing}\n")

# Total missing cells
print(f"Total missing: {df.isnull().sum().sum()} out of {df.size} cells")

# Rows with any missing value
print(f"\nRows with missing data:\n{df[df.isnull().any(axis=1)]}")

▶ Output

Raw data:
         name   age    city   salary            email
0       Rahul  28.0  Mumbai  75000.0   rahul@mail.com
1    Niranjan  31.0    Pune      NaN
2       Viraj   NaN  Mumbai  71000.0   viraj@mail.com
3         NaN  29.0   Delhi  65000.0              N/A
4      Sardar  27.0     NaN  78000.0  sardar@mail.com
5  Prathamesh  33.0  Mumbai  91000.0              NaN

Missing per column:
name      1
age       1
city      1
salary    1
email     1
dtype: int64

Percentage missing:
name      16.7
age       16.7
city      16.7
salary    16.7
email     16.7
dtype: float64

Total missing: 5 out of 30 cells

Rows with missing data:
         name   age    city   salary            email
1    Niranjan  31.0    Pune      NaN
2       Viraj   NaN  Mumbai  71000.0   viraj@mail.com
3         NaN  29.0   Delhi  65000.0              N/A
4      Sardar  27.0     NaN  78000.0  sardar@mail.com
5  Prathamesh  33.0  Mumbai  91000.0              NaN

What happened here: df.isnull() turns every cell into True (missing) or False (present), and .sum() counts the True values down each column. One missing value per column here. Two things surprise beginners. First, None and np.nan both count as missing, so the name in row 3 and the email in row 5 both show up as NaN when printed. Second, the empty string in row 1 (Niranjan’s email) does NOT count as missing.

Pandas sees an empty string as a real value, just a short one. We deal with that trap in the Common Mistakes section. Think of it like a stocktake in a shop: before you reorder anything, you walk the shelves and note exactly which items are missing and how many. Counting missing values is always step one: you cannot fix what you have not measured.

Recipe 2: Handling Missing Values

📄 handle_missing.py: drop or fill strategies

import pandas as pd
import numpy as np

df = pd.DataFrame({
    "name": ["Rahul", "Niranjan", "Viraj", "Pravin", "Sardar"],
    "age": [28, 31, np.nan, 29, 27],
    "salary": [75000, np.nan, 71000, 65000, np.nan],
    "dept": ["Eng", "DS", "Eng", np.nan, "DS"]
})

# Strategy 1: Drop rows with ANY missing value
dropped = df.dropna()
print(f"After dropna():\n{dropped}\n")

# Strategy 2: Drop rows missing in specific column only
dropped_salary = df.dropna(subset=["salary"])
print(f"Drop only if salary missing:\n{dropped_salary}\n")

# Strategy 3: Fill with a constant
filled_const = df.fillna({"dept": "Unknown", "age": 0})
print(f"Fill with constants:\n{filled_const}\n")

# Strategy 4: Fill numeric with mean/median
df_filled = df.copy()
df_filled["age"] = df_filled["age"].fillna(df_filled["age"].median())
df_filled["salary"] = df_filled["salary"].fillna(df_filled["salary"].mean())
df_filled["dept"] = df_filled["dept"].fillna(df_filled["dept"].mode()[0])
print(f"Fill with median/mean/mode:\n{df_filled}")

▶ Output

After dropna():
    name   age   salary dept
0  Rahul  28.0  75000.0  Eng

Drop only if salary missing:
     name   age   salary dept
0   Rahul  28.0  75000.0  Eng
2   Viraj   NaN  71000.0  Eng
3  Pravin  29.0  65000.0  NaN

Fill with constants:
       name   age   salary     dept
0     Rahul  28.0  75000.0      Eng
1  Niranjan  31.0      NaN       DS
2     Viraj   0.0  71000.0      Eng
3    Pravin  29.0  65000.0  Unknown
4    Sardar  27.0      NaN       DS

Fill with median/mean/mode:
       name   age        salary dept
0     Rahul  28.0  75000.000000  Eng
1  Niranjan  31.0  70333.333333   DS
2     Viraj  28.5  71000.000000  Eng
3    Pravin  29.0  65000.000000   DS
4    Sardar  27.0  70333.333333   DS

What happened here: Four strategies, four different results. dropna() is the strictest: it deletes any row with even one gap, so only Rahul survives. Use it only when you have plenty of rows to spare. dropna(subset=["salary"]) is gentler, it drops a row only if the salary is missing, keeping Viraj even though his age is blank. Filling is usually smarter than dropping. For numbers, median resists outliers better than mean, so it is the safer default for skewed data like salaries.

For categories, mode (the most common value) is the natural fill. One detail worth knowing: here both “Eng” and “DS” appear twice, a tie, and mode() returns them sorted, so it picks “DS” for Pravin. The salaries print with long decimals because the mean is a float, that is just how pandas shows it, not an error. A quick analogy: dropping rows is throwing out the whole form because one box was blank; filling is writing in a sensible best guess so you keep the rest of the answers.

Recipe 3: Removing Duplicates

📄 duplicates.py: find and remove duplicate rows

import pandas as pd

df = pd.DataFrame({
    "name": ["Rahul", "Viraj", "Rahul", "Pravin", "Viraj"],
    "email": ["rahul@m.com", "viraj@m.com", "rahul@m.com", "pravin@m.com", "viraj2@m.com"],
    "order": [100, 200, 100, 300, 400]
})

print(f"Original ({len(df)} rows):\n{df}\n")

# Find duplicates (all columns must match)
print(f"Duplicate rows:\n{df[df.duplicated()]}\n")

# Check duplicates on specific columns
print(f"Duplicate names:\n{df[df.duplicated(subset='name', keep=False)]}\n")

# Remove duplicates
deduped = df.drop_duplicates()
print(f"After drop_duplicates ({len(deduped)} rows):\n{deduped}\n")

# Keep last occurrence instead of first
deduped_last = df.drop_duplicates(subset="name", keep="last")
print(f"Keep last per name ({len(deduped_last)} rows):\n{deduped_last}")

▶ Output

Original (5 rows):
     name         email  order
0   Rahul   rahul@m.com    100
1   Viraj   viraj@m.com    200
2   Rahul   rahul@m.com    100
3  Pravin  pravin@m.com    300
4   Viraj  viraj2@m.com    400

Duplicate rows:
    name        email  order
2  Rahul  rahul@m.com    100

Duplicate names:
    name         email  order
0  Rahul   rahul@m.com    100
1  Viraj   viraj@m.com    200
2  Rahul   rahul@m.com    100
4  Viraj  viraj2@m.com    400

After drop_duplicates (4 rows):
     name         email  order
0   Rahul   rahul@m.com    100
1   Viraj   viraj@m.com    200
3  Pravin  pravin@m.com    300
4   Viraj  viraj2@m.com    400

Keep last per name (3 rows):
     name         email  order
2   Rahul   rahul@m.com    100
3  Pravin  pravin@m.com    300
4   Viraj  viraj2@m.com    400

What happened here: By default duplicated() flags a row only when every column matches an earlier row, so the second Rahul (same name, same email, same order) is the lone full duplicate. The subset argument lets you loosen that: duplicated(subset='name', keep=False) marks all rows that share a name, even when other columns differ, which is how both Viraj rows get flagged. That distinction matters in real life. Two Viraj rows with different emails are probably two different people, not a copy you want to delete.

The keep argument decides which copy to keep: “first” (the default), “last”, or False to drop every copy. Think of it like deduplicating your phone contacts: sometimes you keep the newest entry, sometimes the oldest, and you only merge two “Viraj” cards if the number behind them is truly the same.

Recipe 4: Fixing Data Types

📄 fix_dtypes.py: convert columns to correct types

import pandas as pd

df = pd.DataFrame({
    "date": ["2026-01-15", "2026-02-20", "2026-03-10"],
    "revenue": ["1,250.50", "2,100.75", "950.25"],
    "active": ["yes", "no", "yes"],
    "count": ["10", "20", "15"]
})

print(f"Before:\n{df.dtypes}\n")

# Fix dates
df["date"] = pd.to_datetime(df["date"])

# Fix currency strings to float
df["revenue"] = df["revenue"].str.replace(",", "").astype(float)

# Fix yes/no to boolean
df["active"] = df["active"].map({"yes": True, "no": False})

# Fix string numbers to int
df["count"] = df["count"].astype(int)

print(f"After:\n{df.dtypes}\n")
print(df)

▶ Output

Before:
date       str
revenue    str
active     str
count      str
dtype: object

After:
date       datetime64[us]
revenue           float64
active               bool
count               int64
dtype: object

        date  revenue  active  count
0 2026-01-15  1250.50    True     10
1 2026-02-20  2100.75   False     20
2 2026-03-10   950.25    True     15

What happened here: It is like receiving a box of prices written on paper slips: they look like numbers, but until you actually treat them as numbers you cannot add them up. When pandas reads text, it stores everything as a string, so a date, a price, and a yes/no flag all start life as plain text. That is fine for printing but useless for math: you cannot subtract two dates that are really strings, and "1,250.50" will not add up.

So we convert each column to the type it should be. pd.to_datetime() parses the dates, a quick .str.replace(",", "") strips the thousands comma before astype(float), .map() turns “yes” and “no” into real booleans, and astype(int) fixes the counts. One modern detail to flag: on Pandas 3.x (which produced the output above) text columns report their dtype as str rather than the older object, and datetimes land as datetime64[us] (microsecond resolution) instead of the old datetime64[ns].

If you are on Pandas 2.x you will still see object and datetime64[ns]. The cleaning code itself is identical either way.

Recipe 5: Detecting Outliers with IQR

📄 outliers_iqr.py: find and handle outliers

import pandas as pd
import numpy as np

rng = np.random.default_rng(42)
salaries = rng.normal(75000, 10000, 100).astype(int)
# Inject outliers
salaries[0] = 250000
salaries[1] = 5000

df = pd.DataFrame({"salary": salaries})

# IQR method
Q1 = df["salary"].quantile(0.25)
Q3 = df["salary"].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR

outliers = df[(df["salary"] < lower) | (df["salary"] > upper)]
print(f"Q1: {Q1:,.0f}, Q3: {Q3:,.0f}, IQR: {IQR:,.0f}")
print(f"Bounds: [{lower:,.0f}, {upper:,.0f}]")
print(f"Outliers found: {len(outliers)}")
print(f"Outlier values: {sorted(outliers['salary'].tolist())}\n")

# Option 1: Remove outliers
clean = df[(df["salary"] >= lower) & (df["salary"] <= upper)]
print(f"After removal: {len(clean)} rows (was {len(df)})")

# Option 2: Cap outliers (winsorize)
df_capped = df.copy()
df_capped["salary"] = df_capped["salary"].clip(lower=lower, upper=upper)
print(f"After capping: min={df_capped['salary'].min():,.0f}, max={df_capped['salary'].max():,.0f}")

▶ Output

Q1: 69,560, Q3: 80,539, IQR: 10,978
Bounds: [53,093, 97,006]
Outliers found: 2
Outlier values: [5000, 250000]

After removal: 98 rows (was 100)
After capping: min=53,093, max=97,006

What happened here: The IQR method finds values that sit far outside the typical spread. Q1 is the 25th percentile and Q3 is the 75th, so the IQR is the range where the middle half of your data lives. Anything more than 1.5 times the IQR below Q1 or above Q3 is treated as an outlier. Here that flags the two values we planted: a 5,000 salary and a 250,000 salary.

A simple analogy: line up everyone in a class by height, look at the middle group, and anyone standing way taller or way shorter than that group stands out. After that you choose what to do. Removing outliers deletes those rows (98 left from 100). Capping (also called winsorizing) with .clip() keeps the rows but pulls extreme values back to the boundary, so no row is lost and no single giant value can warp your average.

We seeded the random generator with default_rng(42), so you will get these exact numbers; change the seed and your numbers will differ.

One note on scale: when you move from a few hundred thousand rows into the millions, this kind of column-wide scan starts to feel slow in Pandas. Polars 1.41.2 runs the same quantile and filter work in parallel and is often several times faster on large frames. The cleaning ideas in this post carry over directly; only the tool changes.

Common Mistakes

📄 Mistake 1: Empty strings are NOT NaN

import pandas as pd

df = pd.DataFrame({"email": ["rahul@m.com", "", "N/A", None]})
print(df.isnull())  # Only None shows as NaN!

# Fix: replace common missing indicators
df["email"] = df["email"].replace({"": pd.NA, "N/A": pd.NA, "null": pd.NA})

▶ Output

   email
0  False
1  False
2  False
3   True

Why this bites you: Only row 3, the real None, shows up as missing (True). The empty string in row 1 and the text "N/A" in row 2 both look like missing data to a human, but pandas treats them as ordinary values, so isnull() reports False. Your missing-value count comes out too low and the gaps slip through. The fix is one line: use .replace() to turn the usual fake-missing markers ("", "N/A", "null", "None", "-") into a real pd.NA first, then count and clean. Always eyeball a few rows before you trust an automatic count.

Practice Exercises

  1. Exercise 1: Take the messy DataFrame from Recipe 1 and fill the missing values three different ways: the age with the median, the salary with the mean, and the city with the mode. Compare the three results.
  2. Exercise 2: Build a small contacts table with duplicate names and inconsistent casing (like "rahul" and "Rahul"). Standardize the names with .str.strip().str.title(), then remove the duplicates.
  3. Exercise 3: Chain the recipes into one cleaning function that takes a raw DataFrame and returns a clean one: replace fake-missing markers, fill or drop nulls, drop duplicates, fix dtypes, then cap outliers with IQR. Run it twice to confirm it gives the same result.

Conclusion

You now have a repeatable pandas data cleaning workflow: detect and handle missing values with isnull(), dropna(), and fillna(), remove duplicates with drop_duplicates(), fix data types with astype() and pd.to_datetime(), and catch outliers with the IQR method. Run these steps in order, missing values first and outliers last, so one problem never hides another. The honest lesson from Niranjan's 99% model still holds: clean data beats a flattering number every time.

Next up is Pandas data transformation with apply, groupby, and pivot_table, where you turn your freshly cleaned data into real answers. For the full learning path from Python basics to machine learning, visit the Python + AI/ML tutorial series home.

Frequently Asked Questions

Should I drop or fill pandas missing values?

Drop when the missing data is under about 5% of rows and you have plenty of rows to spare. Fill with mean or median for numeric columns when values are missing at random, and median is the safer default because outliers do not drag it. Fill with the mode for categories, and use forward-fill for time series. Never drop blindly: always ask WHY the data is missing first, because the reason often tells you the right fix.

What is the difference between NaN, None, and NA in Pandas?

NaN (a float) is NumPy's missing marker for numeric data. None is Python's own null. pd.NA is Pandas' newer, type-neutral missing value used by the nullable and string dtypes that are standard in Pandas 3.0. In practice you do not have to track which is which: isnull() (or its alias isna()) flags all of them the same way, so use that to detect missing values uniformly.

When should I use IQR vs z-score for outlier detection?

Use IQR when your data might not be normally distributed, because it works on any shape and is not thrown off by the very outliers you are hunting. Use the z-score (values beyond 2 to 3 standard deviations) when your data is roughly bell-shaped. IQR is the more robust, all-purpose choice and is the default recommendation for most data cleaning tasks.

How do I handle missing values in machine learning?

scikit-learn's SimpleImputer fills gaps in a clean, repeatable way that you can fit on training data and reuse on new data. For tree-based models like Random Forest and XGBoost you can often leave NaN as-is, because they handle it natively. Linear models need the gaps filled first. The ML preprocessing tutorial covers this in detail.

Interview Questions on Pandas Data Cleaning

How interviewers actually probe this topic: real scenarios, with answers you can say out loud.

Q: How do you get a quick count of missing values in every column of a DataFrame?

Chain df.isnull().sum(). The isnull() call turns each cell into True or False, and .sum() counts the True values down each column. To see it as a share of rows, do (df.isnull().sum() / len(df) * 100).round(1), and use df.isnull().sum().sum() for the grand total of missing cells.

Q: Your import shows zero missing values, but you can clearly see blanks and "N/A" text in the data. What is going on?

Pandas only treats None, np.nan, and pd.NA as missing. An empty string "" or the text "N/A" is an ordinary value to pandas, so isnull() reports False and your count comes out too low. Fix it by normalizing the fake markers first: df = df.replace({"": pd.NA, "N/A": pd.NA, "null": pd.NA}), then recount. Always eyeball a few rows before trusting an automatic count.

Q: You run drop_duplicates() but duplicate-looking rows survive. What do you check first?

Pandas compares values exactly, so rows that look identical to a human can differ by whitespace or casing, like "Rahul" versus "rahul ". Normalize the key columns before deduping: df["name"] = df["name"].str.strip().str.title(). Also confirm you are deduping on the right columns with the subset argument, since a trailing space in an email or an extra digit in an order id makes the row count as unique.

Q: A revenue column will not sum and reports its dtype as object or str. How do you debug and fix it?

The column is text, not numbers, usually because of thousands commas or a currency symbol like "1,250.50". Strip the offending characters, then cast: df["revenue"] = df["revenue"].str.replace(",", "").astype(float). If a few values are non-numeric, use pd.to_numeric(df["revenue"], errors="coerce") so bad entries become NaN instead of raising, then handle those NaN values.

Q: What does the keep argument do in duplicated() and drop_duplicates()?

keep="first" (the default) marks or drops every copy except the first occurrence, keep="last" keeps the last, and keep=False flags or drops all copies including the originals. Use keep=False with duplicated() when you want to inspect every row involved in a duplicate group, not just the extra copies.

Q: Why cap outliers with clip() instead of deleting the rows?

Capping, also called winsorizing, pulls extreme values back to the IQR boundary with df["col"].clip(lower=lower, upper=upper), so you keep every row and lose no other information those rows carry. Deleting outliers shrinks your sample and can bias the result if the extremes are legitimate. Cap when the row is otherwise valid and you only want to tame one wild value; remove only when the whole record is clearly corrupt.

Want more? pandas official documentation documents everything this post could not fit.

Previous: Pandas: Data Selection with loc, iloc, Boolean Indexing

Next: Pandas: Data Transformation with apply, groupby, pivot_table

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *